Dadoum commited on 2026-07-09 23:34:01
Showing 40 changed files, with 8859 additions and 273 deletions.
| ... | ... |
@@ -3544,7 +3544,9 @@ version = "0.1.64" |
| 3544 | 3544 |
source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3545 | 3545 |
checksum = "48e95040e91503e7425835843e5e985ab1ea440db9b965972021e7b845c2a6ae" |
| 3546 | 3546 |
dependencies = [ |
| 3547 |
+ "async_zip", |
|
| 3547 | 3548 |
"base64", |
| 3549 |
+ "chrono", |
|
| 3548 | 3550 |
"futures", |
| 3549 | 3551 |
"libc", |
| 3550 | 3552 |
"plist", |
| ... | ... |
@@ -7084,12 +7086,15 @@ dependencies = [ |
| 7084 | 7086 |
"adi", |
| 7085 | 7087 |
"adid-proxy", |
| 7086 | 7088 |
"android-coreadi", |
| 7089 |
+ "apple-codesign", |
|
| 7087 | 7090 |
"async_zip", |
| 7088 | 7091 |
"chrono", |
| 7089 | 7092 |
"core-foundation 0.10.0", |
| 7093 |
+ "crc32fast", |
|
| 7090 | 7094 |
"directories", |
| 7091 | 7095 |
"dlopen2", |
| 7092 | 7096 |
"env_logger", |
| 7097 |
+ "flate2", |
|
| 7093 | 7098 |
"futures", |
| 7094 | 7099 |
"futures-lite 2.6.1", |
| 7095 | 7100 |
"gpui", |
| ... | ... |
@@ -7097,20 +7102,24 @@ dependencies = [ |
| 7097 | 7102 |
"gpui_platform", |
| 7098 | 7103 |
"grandslam", |
| 7099 | 7104 |
"idevice", |
| 7105 |
+ "image", |
|
| 7100 | 7106 |
"keyring", |
| 7101 | 7107 |
"libc", |
| 7102 | 7108 |
"library-coreadi", |
| 7103 | 7109 |
"log", |
| 7104 | 7110 |
"ouroboros", |
| 7111 |
+ "pem-rfc7468", |
|
| 7105 | 7112 |
"plist", |
| 7106 | 7113 |
"rand 0.10.2", |
| 7107 | 7114 |
"reqwest", |
| 7108 | 7115 |
"serde", |
| 7109 | 7116 |
"sha1 0.11.0", |
| 7117 |
+ "tempfile", |
|
| 7110 | 7118 |
"thiserror 2.0.18", |
| 7111 | 7119 |
"tokio", |
| 7112 | 7120 |
"toml 0.8.23", |
| 7113 | 7121 |
"uuid", |
| 7122 |
+ "walkdir", |
|
| 7114 | 7123 |
"xcode", |
| 7115 | 7124 |
] |
| 7116 | 7125 |
|
| ... | ... |
@@ -5,10 +5,17 @@ edition = "2024" |
| 5 | 5 |
|
| 6 | 6 |
[dependencies] |
| 7 | 7 |
plist.workspace = true |
| 8 |
- |
|
| 9 |
-tokio.workspace = true |
|
| 10 |
-lief = "0.17" |
|
| 8 |
+thiserror.workspace = true |
|
| 11 | 9 |
sha1.workspace = true |
| 12 | 10 |
sha2.workspace = true |
| 13 |
-futures = "0.3.31" |
|
| 14 |
-log = "0.4.28" |
|
| 11 |
+byteorder = "1.5" |
|
| 12 |
+cms = { version = "0.2.3", features = ["builder", "rsa", "sha2"] }
|
|
| 13 |
+const-oid = { version = "0.9.6", features = ["db"] }
|
|
| 14 |
+der = { version = "0.7.10", features = ["alloc", "derive", "std"] }
|
|
| 15 |
+memmap2 = "0.9" |
|
| 16 |
+pem-rfc7468 = { version = "0.7", features = ["alloc"] }
|
|
| 17 |
+rayon = "1.12" |
|
| 18 |
+rsa = { version = "0.9.9", features = ["sha2"] }
|
|
| 19 |
+sha2_010 = { package = "sha2", version = "0.10" }
|
|
| 20 |
+spki = "0.7.3" |
|
| 21 |
+x509-cert = "0.2.5" |
| ... | ... |
@@ -0,0 +1,684 @@ |
| 1 |
+use crate::error::{CodeSignError, Result};
|
|
| 2 |
+use crate::file_bytes::read_file_bytes; |
|
| 3 |
+use crate::macho::{DEFAULT_CMS_BLOB_RESERVATION, MachOSigningConfig, sign_macho_file};
|
|
| 4 |
+use crate::signature::CmsSigner; |
|
| 5 |
+use plist::{Dictionary, Value};
|
|
| 6 |
+use rayon::prelude::*; |
|
| 7 |
+use sha1::Digest as _; |
|
| 8 |
+use std::collections::BTreeMap; |
|
| 9 |
+use std::fs; |
|
| 10 |
+use std::path::{Path, PathBuf};
|
|
| 11 |
+ |
|
| 12 |
+const FAIRPLAY_DIR: &str = "SC_Info"; |
|
| 13 |
+const CODE_SIGNATURE_DIR: &str = "_CodeSignature"; |
|
| 14 |
+const CODE_RESOURCES_FILE: &str = "CodeResources"; |
|
| 15 |
+ |
|
| 16 |
+pub struct BundleSigningSettings<'a> {
|
|
| 17 |
+ pub team_id: String, |
|
| 18 |
+ pub main_entitlements: Dictionary, |
|
| 19 |
+ pub entitlements_by_bundle_id: BTreeMap<String, Dictionary>, |
|
| 20 |
+ pub cms_signer: Option<&'a dyn CmsSigner>, |
|
| 21 |
+ pub embedded_mobileprovision: Option<&'a [u8]>, |
|
| 22 |
+ pub embedded_mobileprovisions_by_bundle_id: BTreeMap<String, &'a [u8]>, |
|
| 23 |
+ pub cms_blob_reservation: usize, |
|
| 24 |
+} |
|
| 25 |
+ |
|
| 26 |
+impl<'a> BundleSigningSettings<'a> {
|
|
| 27 |
+ pub fn new( |
|
| 28 |
+ team_id: impl Into<String>, |
|
| 29 |
+ main_entitlements: Dictionary, |
|
| 30 |
+ cms_signer: Option<&'a dyn CmsSigner>, |
|
| 31 |
+ ) -> Self {
|
|
| 32 |
+ Self {
|
|
| 33 |
+ team_id: team_id.into(), |
|
| 34 |
+ main_entitlements, |
|
| 35 |
+ entitlements_by_bundle_id: BTreeMap::new(), |
|
| 36 |
+ cms_signer, |
|
| 37 |
+ embedded_mobileprovision: None, |
|
| 38 |
+ embedded_mobileprovisions_by_bundle_id: BTreeMap::new(), |
|
| 39 |
+ cms_blob_reservation: DEFAULT_CMS_BLOB_RESERVATION, |
|
| 40 |
+ } |
|
| 41 |
+ } |
|
| 42 |
+} |
|
| 43 |
+ |
|
| 44 |
+#[derive(Debug)] |
|
| 45 |
+pub struct Bundle {
|
|
| 46 |
+ path: PathBuf, |
|
| 47 |
+ app_info: Dictionary, |
|
| 48 |
+} |
|
| 49 |
+ |
|
| 50 |
+impl Bundle {
|
|
| 51 |
+ pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
|
|
| 52 |
+ let path = path.into(); |
|
| 53 |
+ let info_path = path.join("Info.plist");
|
|
| 54 |
+ if !info_path.exists() {
|
|
| 55 |
+ return Err(CodeSignError::MissingInfoPlist(info_path)); |
|
| 56 |
+ } |
|
| 57 |
+ |
|
| 58 |
+ let file = |
|
| 59 |
+ fs::File::open(&info_path).map_err(|source| CodeSignError::io(&info_path, source))?; |
|
| 60 |
+ let app_info = match Value::from_reader(file)? {
|
|
| 61 |
+ Value::Dictionary(dict) => dict, |
|
| 62 |
+ _ => {
|
|
| 63 |
+ return Err(CodeSignError::macho( |
|
| 64 |
+ &info_path, |
|
| 65 |
+ "Info.plist is not a dictionary", |
|
| 66 |
+ )); |
|
| 67 |
+ } |
|
| 68 |
+ }; |
|
| 69 |
+ |
|
| 70 |
+ Ok(Self { path, app_info })
|
|
| 71 |
+ } |
|
| 72 |
+ |
|
| 73 |
+ pub fn path(&self) -> &Path {
|
|
| 74 |
+ &self.path |
|
| 75 |
+ } |
|
| 76 |
+ |
|
| 77 |
+ pub fn bundle_identifier(&self) -> Result<&str> {
|
|
| 78 |
+ self.info_string("CFBundleIdentifier")
|
|
| 79 |
+ } |
|
| 80 |
+ |
|
| 81 |
+ pub fn executable_name(&self) -> Result<&str> {
|
|
| 82 |
+ self.info_string("CFBundleExecutable")
|
|
| 83 |
+ } |
|
| 84 |
+ |
|
| 85 |
+ pub fn sign(&mut self, settings: &BundleSigningSettings<'_>) -> Result<CodeResourcesMaps> {
|
|
| 86 |
+ self.sign_inner(settings, true) |
|
| 87 |
+ } |
|
| 88 |
+ |
|
| 89 |
+ fn sign_inner( |
|
| 90 |
+ &mut self, |
|
| 91 |
+ settings: &BundleSigningSettings<'_>, |
|
| 92 |
+ is_root: bool, |
|
| 93 |
+ ) -> Result<CodeResourcesMaps> {
|
|
| 94 |
+ remove_fairplay_dir(&self.path)?; |
|
| 95 |
+ |
|
| 96 |
+ let bundle_id = self.bundle_identifier()?.to_string(); |
|
| 97 |
+ let executable = self.executable_name()?.to_string(); |
|
| 98 |
+ let sub_bundles = self.sub_bundles()?; |
|
| 99 |
+ let sub_bundles_for_hashing = sub_bundles.clone(); |
|
| 100 |
+ |
|
| 101 |
+ let code_signature_dir = self.path.join(CODE_SIGNATURE_DIR); |
|
| 102 |
+ fs::create_dir_all(&code_signature_dir) |
|
| 103 |
+ .map_err(|source| CodeSignError::io(&code_signature_dir, source))?; |
|
| 104 |
+ |
|
| 105 |
+ let code_resources_path = code_signature_dir.join(CODE_RESOURCES_FILE); |
|
| 106 |
+ if code_resources_path.exists() {
|
|
| 107 |
+ fs::remove_file(&code_resources_path) |
|
| 108 |
+ .map_err(|source| CodeSignError::io(&code_resources_path, source))?; |
|
| 109 |
+ } |
|
| 110 |
+ |
|
| 111 |
+ let info_data = encode_binary_plist(&self.app_info)?; |
|
| 112 |
+ let info_path = self.path.join("Info.plist");
|
|
| 113 |
+ fs::write(&info_path, &info_data) |
|
| 114 |
+ .map_err(|source| CodeSignError::io(&info_path, source))?; |
|
| 115 |
+ let mobileprovision = settings |
|
| 116 |
+ .embedded_mobileprovisions_by_bundle_id |
|
| 117 |
+ .get(&bundle_id) |
|
| 118 |
+ .copied() |
|
| 119 |
+ .or_else(|| {
|
|
| 120 |
+ if is_root {
|
|
| 121 |
+ settings.embedded_mobileprovision |
|
| 122 |
+ } else {
|
|
| 123 |
+ None |
|
| 124 |
+ } |
|
| 125 |
+ }); |
|
| 126 |
+ if let Some(mobileprovision) = mobileprovision {
|
|
| 127 |
+ let profile_path = self.path.join("embedded.mobileprovision");
|
|
| 128 |
+ fs::write(&profile_path, mobileprovision) |
|
| 129 |
+ .map_err(|source| CodeSignError::io(&profile_path, source))?; |
|
| 130 |
+ } |
|
| 131 |
+ |
|
| 132 |
+ let (sub_results, bundle_files) = rayon::join( |
|
| 133 |
+ || sign_sub_bundles(&self.path, sub_bundles, settings), |
|
| 134 |
+ || collect_own_bundle_files(&self.path, &executable, &sub_bundles_for_hashing), |
|
| 135 |
+ ); |
|
| 136 |
+ let sub_results = sub_results?; |
|
| 137 |
+ let bundle_files = bundle_files?; |
|
| 138 |
+ sign_libraries(&bundle_files.libraries, settings)?; |
|
| 139 |
+ let resource_hashes = hash_resource_files(&self.path, &bundle_files.resource_files)?; |
|
| 140 |
+ |
|
| 141 |
+ let mut maps = CodeResourcesMaps::default(); |
|
| 142 |
+ |
|
| 143 |
+ for sub in &sub_results {
|
|
| 144 |
+ merge_sub_bundle_resources(&mut maps, &self.path, sub)?; |
|
| 145 |
+ } |
|
| 146 |
+ |
|
| 147 |
+ for hash in resource_hashes {
|
|
| 148 |
+ maps.insert_hash(hash); |
|
| 149 |
+ } |
|
| 150 |
+ |
|
| 151 |
+ let code_resources = encode_code_resources(&maps)?; |
|
| 152 |
+ fs::write(&code_resources_path, &code_resources) |
|
| 153 |
+ .map_err(|source| CodeSignError::io(&code_resources_path, source))?; |
|
| 154 |
+ |
|
| 155 |
+ let empty_entitlements = Dictionary::new(); |
|
| 156 |
+ let bundle_entitlements = settings |
|
| 157 |
+ .entitlements_by_bundle_id |
|
| 158 |
+ .get(&bundle_id) |
|
| 159 |
+ .or_else(|| is_root.then_some(&settings.main_entitlements)) |
|
| 160 |
+ .unwrap_or(&empty_entitlements); |
|
| 161 |
+ |
|
| 162 |
+ let mut config = MachOSigningConfig::new( |
|
| 163 |
+ &bundle_id, |
|
| 164 |
+ &settings.team_id, |
|
| 165 |
+ bundle_entitlements, |
|
| 166 |
+ settings.cms_signer, |
|
| 167 |
+ ); |
|
| 168 |
+ config.cms_blob_reservation = settings.cms_blob_reservation; |
|
| 169 |
+ config.info_plist = Some(&info_data); |
|
| 170 |
+ config.code_resources = Some(&code_resources); |
|
| 171 |
+ sign_macho_file(&self.path.join(&executable), &config)?; |
|
| 172 |
+ |
|
| 173 |
+ Ok(maps) |
|
| 174 |
+ } |
|
| 175 |
+ |
|
| 176 |
+ fn info_string(&self, key: &'static str) -> Result<&str> {
|
|
| 177 |
+ self.app_info |
|
| 178 |
+ .get(key) |
|
| 179 |
+ .and_then(Value::as_string) |
|
| 180 |
+ .ok_or(CodeSignError::MissingInfoString(key)) |
|
| 181 |
+ } |
|
| 182 |
+ |
|
| 183 |
+ fn sub_bundles(&self) -> Result<Vec<PathBuf>> {
|
|
| 184 |
+ let mut bundles = Vec::new(); |
|
| 185 |
+ for folder in ["Frameworks", "PlugIns"] {
|
|
| 186 |
+ let dir = self.path.join(folder); |
|
| 187 |
+ if !dir.exists() {
|
|
| 188 |
+ continue; |
|
| 189 |
+ } |
|
| 190 |
+ |
|
| 191 |
+ for entry in fs::read_dir(&dir).map_err(|source| CodeSignError::io(&dir, source))? {
|
|
| 192 |
+ let entry = entry.map_err(|source| CodeSignError::io(&dir, source))?; |
|
| 193 |
+ let path = entry.path(); |
|
| 194 |
+ if entry |
|
| 195 |
+ .file_type() |
|
| 196 |
+ .map_err(|source| CodeSignError::io(&path, source))? |
|
| 197 |
+ .is_dir() |
|
| 198 |
+ && path.join("Info.plist").exists()
|
|
| 199 |
+ {
|
|
| 200 |
+ bundles.push(path); |
|
| 201 |
+ } |
|
| 202 |
+ } |
|
| 203 |
+ } |
|
| 204 |
+ bundles.sort(); |
|
| 205 |
+ Ok(bundles) |
|
| 206 |
+ } |
|
| 207 |
+} |
|
| 208 |
+ |
|
| 209 |
+#[derive(Debug)] |
|
| 210 |
+struct SubBundleResult {
|
|
| 211 |
+ path: PathBuf, |
|
| 212 |
+ relative_root: String, |
|
| 213 |
+ executable: String, |
|
| 214 |
+ resources: CodeResourcesMaps, |
|
| 215 |
+} |
|
| 216 |
+ |
|
| 217 |
+#[derive(Clone, Debug)] |
|
| 218 |
+struct FileHash {
|
|
| 219 |
+ relative_path: String, |
|
| 220 |
+ sha1: [u8; 20], |
|
| 221 |
+ sha256: [u8; 32], |
|
| 222 |
+ optional: bool, |
|
| 223 |
+} |
|
| 224 |
+ |
|
| 225 |
+#[derive(Debug)] |
|
| 226 |
+struct BundleFiles {
|
|
| 227 |
+ resource_files: Vec<PathBuf>, |
|
| 228 |
+ libraries: Vec<PathBuf>, |
|
| 229 |
+} |
|
| 230 |
+ |
|
| 231 |
+fn merge_sub_bundle_resources( |
|
| 232 |
+ maps: &mut CodeResourcesMaps, |
|
| 233 |
+ parent_path: &Path, |
|
| 234 |
+ sub: &SubBundleResult, |
|
| 235 |
+) -> Result<()> {
|
|
| 236 |
+ maps.merge_rerooted(&sub.relative_root, &sub.resources); |
|
| 237 |
+ maps.insert_hash(hash_bundle_file( |
|
| 238 |
+ parent_path, |
|
| 239 |
+ &sub.path.join(&sub.executable), |
|
| 240 |
+ )?); |
|
| 241 |
+ maps.insert_hash(hash_bundle_file( |
|
| 242 |
+ parent_path, |
|
| 243 |
+ &sub.path.join(CODE_SIGNATURE_DIR).join(CODE_RESOURCES_FILE), |
|
| 244 |
+ )?); |
|
| 245 |
+ Ok(()) |
|
| 246 |
+} |
|
| 247 |
+ |
|
| 248 |
+#[derive(Clone, Debug, Default)] |
|
| 249 |
+pub struct CodeResourcesMaps {
|
|
| 250 |
+ pub files: Dictionary, |
|
| 251 |
+ pub files2: Dictionary, |
|
| 252 |
+ hashes: Vec<FileHash>, |
|
| 253 |
+} |
|
| 254 |
+ |
|
| 255 |
+impl CodeResourcesMaps {
|
|
| 256 |
+ fn insert_hash(&mut self, hash: FileHash) {
|
|
| 257 |
+ self.hashes.push(hash.clone()); |
|
| 258 |
+ let sha1 = hash.sha1.to_vec(); |
|
| 259 |
+ if !omit_from_files(&hash.relative_path) {
|
|
| 260 |
+ if hash.optional {
|
|
| 261 |
+ let mut file = Dictionary::new(); |
|
| 262 |
+ file.insert("hash".to_string(), Value::Data(sha1.clone()));
|
|
| 263 |
+ file.insert("optional".to_string(), Value::Boolean(true));
|
|
| 264 |
+ self.files |
|
| 265 |
+ .insert(hash.relative_path.clone(), Value::Dictionary(file)); |
|
| 266 |
+ } else {
|
|
| 267 |
+ self.files |
|
| 268 |
+ .insert(hash.relative_path.clone(), Value::Data(sha1.clone())); |
|
| 269 |
+ } |
|
| 270 |
+ } |
|
| 271 |
+ |
|
| 272 |
+ if !omit_from_files2(&hash.relative_path) {
|
|
| 273 |
+ let mut file2 = Dictionary::new(); |
|
| 274 |
+ file2.insert("hash".to_string(), Value::Data(sha1));
|
|
| 275 |
+ file2.insert("hash2".to_string(), Value::Data(hash.sha256.to_vec()));
|
|
| 276 |
+ if hash.optional {
|
|
| 277 |
+ file2.insert("optional".to_string(), Value::Boolean(true));
|
|
| 278 |
+ } |
|
| 279 |
+ self.files2 |
|
| 280 |
+ .insert(hash.relative_path, Value::Dictionary(file2)); |
|
| 281 |
+ } |
|
| 282 |
+ } |
|
| 283 |
+ |
|
| 284 |
+ fn merge_rerooted(&mut self, root: &str, other: &Self) {
|
|
| 285 |
+ for hash in &other.hashes {
|
|
| 286 |
+ let mut hash = hash.clone(); |
|
| 287 |
+ hash.relative_path = join_relative(root, &hash.relative_path); |
|
| 288 |
+ self.insert_hash(hash); |
|
| 289 |
+ } |
|
| 290 |
+ } |
|
| 291 |
+} |
|
| 292 |
+ |
|
| 293 |
+fn omit_from_files(relative_path: &str) -> bool {
|
|
| 294 |
+ relative_path.ends_with(".lproj/locversion.plist")
|
|
| 295 |
+} |
|
| 296 |
+ |
|
| 297 |
+fn omit_from_files2(relative_path: &str) -> bool {
|
|
| 298 |
+ omit_from_files(relative_path) |
|
| 299 |
+ || relative_path == "Info.plist" |
|
| 300 |
+ || relative_path == "PkgInfo" |
|
| 301 |
+ || relative_path == ".DS_Store" |
|
| 302 |
+ || relative_path.ends_with("/.DS_Store")
|
|
| 303 |
+} |
|
| 304 |
+ |
|
| 305 |
+pub fn sign_bundle(path: impl Into<PathBuf>, settings: &BundleSigningSettings<'_>) -> Result<()> {
|
|
| 306 |
+ let mut bundle = Bundle::open(path)?; |
|
| 307 |
+ bundle.sign(settings)?; |
|
| 308 |
+ Ok(()) |
|
| 309 |
+} |
|
| 310 |
+ |
|
| 311 |
+fn sign_sub_bundles( |
|
| 312 |
+ root_path: &Path, |
|
| 313 |
+ sub_bundles: Vec<PathBuf>, |
|
| 314 |
+ settings: &BundleSigningSettings<'_>, |
|
| 315 |
+) -> Result<Vec<SubBundleResult>> {
|
|
| 316 |
+ sub_bundles |
|
| 317 |
+ .into_par_iter() |
|
| 318 |
+ .map(|path| {
|
|
| 319 |
+ let mut bundle = Bundle::open(&path)?; |
|
| 320 |
+ let result = bundle.sign_inner(settings, false)?; |
|
| 321 |
+ let relative_root = relative_path_string(root_path, &path)?; |
|
| 322 |
+ let executable = bundle.executable_name()?.to_string(); |
|
| 323 |
+ Ok(SubBundleResult {
|
|
| 324 |
+ path, |
|
| 325 |
+ relative_root, |
|
| 326 |
+ executable, |
|
| 327 |
+ resources: result, |
|
| 328 |
+ }) |
|
| 329 |
+ }) |
|
| 330 |
+ .collect() |
|
| 331 |
+} |
|
| 332 |
+ |
|
| 333 |
+fn collect_own_bundle_files( |
|
| 334 |
+ bundle_path: &Path, |
|
| 335 |
+ executable: &str, |
|
| 336 |
+ sub_bundles: &[PathBuf], |
|
| 337 |
+) -> Result<BundleFiles> {
|
|
| 338 |
+ let mut resource_files = Vec::new(); |
|
| 339 |
+ let mut libraries = Vec::new(); |
|
| 340 |
+ |
|
| 341 |
+ walk_files_pruned( |
|
| 342 |
+ bundle_path, |
|
| 343 |
+ &mut |dir| !sub_bundles.iter().any(|sub_bundle| sub_bundle == dir), |
|
| 344 |
+ &mut |path| {
|
|
| 345 |
+ let relative = relative_path_string(bundle_path, path)?; |
|
| 346 |
+ if path.extension().and_then(|value| value.to_str()) == Some("dylib") {
|
|
| 347 |
+ libraries.push(path.to_path_buf()); |
|
| 348 |
+ } |
|
| 349 |
+ if should_hash_resource(&relative, executable) {
|
|
| 350 |
+ resource_files.push(path.to_path_buf()); |
|
| 351 |
+ } |
|
| 352 |
+ Ok(()) |
|
| 353 |
+ }, |
|
| 354 |
+ )?; |
|
| 355 |
+ |
|
| 356 |
+ resource_files.sort(); |
|
| 357 |
+ libraries.sort(); |
|
| 358 |
+ |
|
| 359 |
+ Ok(BundleFiles {
|
|
| 360 |
+ resource_files, |
|
| 361 |
+ libraries, |
|
| 362 |
+ }) |
|
| 363 |
+} |
|
| 364 |
+ |
|
| 365 |
+fn sign_libraries(libraries: &[PathBuf], settings: &BundleSigningSettings<'_>) -> Result<()> {
|
|
| 366 |
+ libraries |
|
| 367 |
+ .par_iter() |
|
| 368 |
+ .map(|path| {
|
|
| 369 |
+ let identifier = path |
|
| 370 |
+ .file_name() |
|
| 371 |
+ .and_then(|value| value.to_str()) |
|
| 372 |
+ .ok_or_else(|| CodeSignError::InvalidBundlePath(path.clone()))?; |
|
| 373 |
+ let entitlements = Dictionary::new(); |
|
| 374 |
+ let mut config = MachOSigningConfig::new( |
|
| 375 |
+ identifier, |
|
| 376 |
+ &settings.team_id, |
|
| 377 |
+ &entitlements, |
|
| 378 |
+ settings.cms_signer, |
|
| 379 |
+ ); |
|
| 380 |
+ config.cms_blob_reservation = settings.cms_blob_reservation; |
|
| 381 |
+ sign_macho_file(path, &config) |
|
| 382 |
+ }) |
|
| 383 |
+ .collect::<Result<Vec<_>>>()?; |
|
| 384 |
+ Ok(()) |
|
| 385 |
+} |
|
| 386 |
+ |
|
| 387 |
+fn hash_resource_files(bundle_path: &Path, resource_files: &[PathBuf]) -> Result<Vec<FileHash>> {
|
|
| 388 |
+ resource_files |
|
| 389 |
+ .par_iter() |
|
| 390 |
+ .map(|path| hash_bundle_file(bundle_path, path)) |
|
| 391 |
+ .collect() |
|
| 392 |
+} |
|
| 393 |
+ |
|
| 394 |
+fn remove_fairplay_dir(bundle_path: &Path) -> Result<()> {
|
|
| 395 |
+ let fairplay_path = bundle_path.join(FAIRPLAY_DIR); |
|
| 396 |
+ if fairplay_path.exists() {
|
|
| 397 |
+ fs::remove_dir_all(&fairplay_path) |
|
| 398 |
+ .map_err(|source| CodeSignError::io(&fairplay_path, source))?; |
|
| 399 |
+ } |
|
| 400 |
+ Ok(()) |
|
| 401 |
+} |
|
| 402 |
+ |
|
| 403 |
+fn walk_files_pruned( |
|
| 404 |
+ root: &Path, |
|
| 405 |
+ should_descend: &mut impl FnMut(&Path) -> bool, |
|
| 406 |
+ visitor: &mut impl FnMut(&Path) -> Result<()>, |
|
| 407 |
+) -> Result<()> {
|
|
| 408 |
+ let mut stack = vec![root.to_path_buf()]; |
|
| 409 |
+ while let Some(dir) = stack.pop() {
|
|
| 410 |
+ for entry in fs::read_dir(&dir).map_err(|source| CodeSignError::io(&dir, source))? {
|
|
| 411 |
+ let entry = entry.map_err(|source| CodeSignError::io(&dir, source))?; |
|
| 412 |
+ let path = entry.path(); |
|
| 413 |
+ let file_type = entry |
|
| 414 |
+ .file_type() |
|
| 415 |
+ .map_err(|source| CodeSignError::io(&path, source))?; |
|
| 416 |
+ if file_type.is_dir() && should_descend(&path) {
|
|
| 417 |
+ stack.push(path); |
|
| 418 |
+ } else if file_type.is_file() {
|
|
| 419 |
+ visitor(&path)?; |
|
| 420 |
+ } |
|
| 421 |
+ } |
|
| 422 |
+ } |
|
| 423 |
+ Ok(()) |
|
| 424 |
+} |
|
| 425 |
+ |
|
| 426 |
+fn should_hash_resource(relative: &str, executable: &str) -> bool {
|
|
| 427 |
+ if relative == executable || relative.starts_with(FAIRPLAY_DIR) {
|
|
| 428 |
+ return false; |
|
| 429 |
+ } |
|
| 430 |
+ |
|
| 431 |
+ for nested_root in ["Frameworks/", "PlugIns/"] {
|
|
| 432 |
+ if let Some(rest) = relative.strip_prefix(nested_root) |
|
| 433 |
+ && rest.contains('/')
|
|
| 434 |
+ {
|
|
| 435 |
+ return false; |
|
| 436 |
+ } |
|
| 437 |
+ } |
|
| 438 |
+ |
|
| 439 |
+ true |
|
| 440 |
+} |
|
| 441 |
+ |
|
| 442 |
+fn hash_bundle_file(bundle_path: &Path, path: &Path) -> Result<FileHash> {
|
|
| 443 |
+ let relative_path = relative_path_string(bundle_path, path)?; |
|
| 444 |
+ let data = read_file_bytes(path)?; |
|
| 445 |
+ let (sha1, sha256) = hash_file_data(data.as_slice()); |
|
| 446 |
+ Ok(FileHash {
|
|
| 447 |
+ optional: relative_path.contains(".lproj"),
|
|
| 448 |
+ relative_path, |
|
| 449 |
+ sha1, |
|
| 450 |
+ sha256, |
|
| 451 |
+ }) |
|
| 452 |
+} |
|
| 453 |
+ |
|
| 454 |
+fn hash_file_data(data: &[u8]) -> ([u8; 20], [u8; 32]) {
|
|
| 455 |
+ if data.len() >= 1024 * 1024 {
|
|
| 456 |
+ rayon::join( |
|
| 457 |
+ || sha1::Sha1::digest(data).into(), |
|
| 458 |
+ || sha2::Sha256::digest(data).into(), |
|
| 459 |
+ ) |
|
| 460 |
+ } else {
|
|
| 461 |
+ ( |
|
| 462 |
+ sha1::Sha1::digest(data).into(), |
|
| 463 |
+ sha2::Sha256::digest(data).into(), |
|
| 464 |
+ ) |
|
| 465 |
+ } |
|
| 466 |
+} |
|
| 467 |
+ |
|
| 468 |
+fn encode_code_resources(maps: &CodeResourcesMaps) -> Result<Vec<u8>> {
|
|
| 469 |
+ let mut code_resources = Dictionary::new(); |
|
| 470 |
+ code_resources.insert("files".to_string(), Value::Dictionary(maps.files.clone()));
|
|
| 471 |
+ code_resources.insert("files2".to_string(), Value::Dictionary(maps.files2.clone()));
|
|
| 472 |
+ code_resources.insert("rules".to_string(), rules());
|
|
| 473 |
+ code_resources.insert("rules2".to_string(), rules2());
|
|
| 474 |
+ |
|
| 475 |
+ let mut data = Vec::new(); |
|
| 476 |
+ plist::to_writer_xml(&mut data, &Value::Dictionary(code_resources))?; |
|
| 477 |
+ Ok(data) |
|
| 478 |
+} |
|
| 479 |
+ |
|
| 480 |
+fn rules() -> Value {
|
|
| 481 |
+ let mut rules = Dictionary::new(); |
|
| 482 |
+ rules.insert("^.*".to_string(), Value::Boolean(true));
|
|
| 483 |
+ rules.insert( |
|
| 484 |
+ "^.*\\.lproj/".to_string(), |
|
| 485 |
+ weighted_rule([("optional", Value::Boolean(true))], 1000.0),
|
|
| 486 |
+ ); |
|
| 487 |
+ rules.insert( |
|
| 488 |
+ "^.*\\.lproj/locversion.plist$".to_string(), |
|
| 489 |
+ weighted_rule([("omit", Value::Boolean(true))], 1100.0),
|
|
| 490 |
+ ); |
|
| 491 |
+ rules.insert("^Base\\.lproj/".to_string(), weight_only_rule(1010.0));
|
|
| 492 |
+ rules.insert("^version.plist$".to_string(), Value::Boolean(true));
|
|
| 493 |
+ Value::Dictionary(rules) |
|
| 494 |
+} |
|
| 495 |
+ |
|
| 496 |
+fn rules2() -> Value {
|
|
| 497 |
+ let mut rules = Dictionary::new(); |
|
| 498 |
+ rules.insert(".*\\.dSYM($|/)".to_string(), weight_only_rule(11.0));
|
|
| 499 |
+ rules.insert( |
|
| 500 |
+ "^(.*/)?\\.DS_Store$".to_string(), |
|
| 501 |
+ weighted_rule([("omit", Value::Boolean(true))], 2000.0),
|
|
| 502 |
+ ); |
|
| 503 |
+ rules.insert("^.*".to_string(), Value::Boolean(true));
|
|
| 504 |
+ rules.insert( |
|
| 505 |
+ "^.*\\.lproj/".to_string(), |
|
| 506 |
+ weighted_rule([("optional", Value::Boolean(true))], 1000.0),
|
|
| 507 |
+ ); |
|
| 508 |
+ rules.insert( |
|
| 509 |
+ "^.*\\.lproj/locversion.plist$".to_string(), |
|
| 510 |
+ weighted_rule([("omit", Value::Boolean(true))], 1100.0),
|
|
| 511 |
+ ); |
|
| 512 |
+ rules.insert("^Base\\.lproj/".to_string(), weight_only_rule(1010.0));
|
|
| 513 |
+ rules.insert( |
|
| 514 |
+ "^Info\\.plist$".to_string(), |
|
| 515 |
+ weighted_rule([("omit", Value::Boolean(true))], 20.0),
|
|
| 516 |
+ ); |
|
| 517 |
+ rules.insert( |
|
| 518 |
+ "^PkgInfo$".to_string(), |
|
| 519 |
+ weighted_rule([("omit", Value::Boolean(true))], 20.0),
|
|
| 520 |
+ ); |
|
| 521 |
+ rules.insert( |
|
| 522 |
+ "^embedded\\.provisionprofile$".to_string(), |
|
| 523 |
+ weight_only_rule(20.0), |
|
| 524 |
+ ); |
|
| 525 |
+ rules.insert("^version\\.plist$".to_string(), weight_only_rule(20.0));
|
|
| 526 |
+ Value::Dictionary(rules) |
|
| 527 |
+} |
|
| 528 |
+ |
|
| 529 |
+fn weighted_rule<const N: usize>(entries: [(&str, Value); N], weight: f64) -> Value {
|
|
| 530 |
+ let mut dict = Dictionary::new(); |
|
| 531 |
+ for (key, value) in entries {
|
|
| 532 |
+ dict.insert(key.to_string(), value); |
|
| 533 |
+ } |
|
| 534 |
+ dict.insert("weight".to_string(), Value::Real(weight));
|
|
| 535 |
+ Value::Dictionary(dict) |
|
| 536 |
+} |
|
| 537 |
+ |
|
| 538 |
+fn weight_only_rule(weight: f64) -> Value {
|
|
| 539 |
+ weighted_rule::<0>([], weight) |
|
| 540 |
+} |
|
| 541 |
+ |
|
| 542 |
+fn encode_binary_plist(dict: &Dictionary) -> Result<Vec<u8>> {
|
|
| 543 |
+ let mut data = Vec::new(); |
|
| 544 |
+ plist::to_writer_binary(&mut data, &Value::Dictionary(dict.clone()))?; |
|
| 545 |
+ Ok(data) |
|
| 546 |
+} |
|
| 547 |
+ |
|
| 548 |
+fn relative_path_string(root: &Path, path: &Path) -> Result<String> {
|
|
| 549 |
+ let relative = path |
|
| 550 |
+ .strip_prefix(root) |
|
| 551 |
+ .map_err(|_| CodeSignError::InvalidBundlePath(path.to_path_buf()))?; |
|
| 552 |
+ path_to_forward_slashes(relative) |
|
| 553 |
+} |
|
| 554 |
+ |
|
| 555 |
+fn path_to_forward_slashes(path: &Path) -> Result<String> {
|
|
| 556 |
+ let mut parts = Vec::new(); |
|
| 557 |
+ for component in path.components() {
|
|
| 558 |
+ parts.push( |
|
| 559 |
+ component |
|
| 560 |
+ .as_os_str() |
|
| 561 |
+ .to_str() |
|
| 562 |
+ .ok_or_else(|| CodeSignError::InvalidBundlePath(path.to_path_buf()))?, |
|
| 563 |
+ ); |
|
| 564 |
+ } |
|
| 565 |
+ Ok(parts.join("/"))
|
|
| 566 |
+} |
|
| 567 |
+ |
|
| 568 |
+fn join_relative(root: &str, child: &str) -> String {
|
|
| 569 |
+ if root.is_empty() {
|
|
| 570 |
+ child.to_string() |
|
| 571 |
+ } else if child.is_empty() {
|
|
| 572 |
+ root.to_string() |
|
| 573 |
+ } else {
|
|
| 574 |
+ format!("{root}/{child}")
|
|
| 575 |
+ } |
|
| 576 |
+} |
|
| 577 |
+ |
|
| 578 |
+#[cfg(test)] |
|
| 579 |
+mod tests {
|
|
| 580 |
+ use super::*; |
|
| 581 |
+ |
|
| 582 |
+ fn test_hash(relative_path: &str) -> FileHash {
|
|
| 583 |
+ FileHash {
|
|
| 584 |
+ relative_path: relative_path.to_string(), |
|
| 585 |
+ sha1: [1; 20], |
|
| 586 |
+ sha256: [2; 32], |
|
| 587 |
+ optional: false, |
|
| 588 |
+ } |
|
| 589 |
+ } |
|
| 590 |
+ |
|
| 591 |
+ #[test] |
|
| 592 |
+ fn files2_omits_only_the_current_bundles_info_and_pkginfo() {
|
|
| 593 |
+ let mut child = CodeResourcesMaps::default(); |
|
| 594 |
+ child.insert_hash(test_hash("Info.plist"));
|
|
| 595 |
+ child.insert_hash(test_hash("PkgInfo"));
|
|
| 596 |
+ |
|
| 597 |
+ assert!(child.files.contains_key("Info.plist"));
|
|
| 598 |
+ assert!(!child.files2.contains_key("Info.plist"));
|
|
| 599 |
+ assert!(!child.files2.contains_key("PkgInfo"));
|
|
| 600 |
+ |
|
| 601 |
+ let mut parent = CodeResourcesMaps::default(); |
|
| 602 |
+ parent.merge_rerooted("PlugIns/Widget.appex", &child);
|
|
| 603 |
+ |
|
| 604 |
+ assert!( |
|
| 605 |
+ parent |
|
| 606 |
+ .files2 |
|
| 607 |
+ .contains_key("PlugIns/Widget.appex/Info.plist")
|
|
| 608 |
+ ); |
|
| 609 |
+ assert!(parent.files2.contains_key("PlugIns/Widget.appex/PkgInfo"));
|
|
| 610 |
+ } |
|
| 611 |
+ |
|
| 612 |
+ #[test] |
|
| 613 |
+ fn direct_dylibs_are_hashed_from_their_final_signed_contents() {
|
|
| 614 |
+ let test_root = std::env::temp_dir().join(format!( |
|
| 615 |
+ "apple-codesign-dylib-resources-{}-{}",
|
|
| 616 |
+ std::process::id(), |
|
| 617 |
+ std::time::SystemTime::now() |
|
| 618 |
+ .duration_since(std::time::UNIX_EPOCH) |
|
| 619 |
+ .unwrap() |
|
| 620 |
+ .as_nanos() |
|
| 621 |
+ )); |
|
| 622 |
+ let dylib = test_root.join("Frameworks/Patch.dylib");
|
|
| 623 |
+ fs::create_dir_all(dylib.parent().unwrap()).unwrap(); |
|
| 624 |
+ fs::write(&dylib, b"unsigned").unwrap(); |
|
| 625 |
+ |
|
| 626 |
+ let bundle_files = collect_own_bundle_files(&test_root, "Main", &[]).unwrap(); |
|
| 627 |
+ assert_eq!(bundle_files.libraries, vec![dylib.clone()]); |
|
| 628 |
+ assert_eq!(bundle_files.resource_files, vec![dylib.clone()]); |
|
| 629 |
+ |
|
| 630 |
+ fs::write(&dylib, b"signed").unwrap(); |
|
| 631 |
+ let hashes = hash_resource_files(&test_root, &bundle_files.resource_files).unwrap(); |
|
| 632 |
+ let expected_sha256: [u8; 32] = sha2::Sha256::digest(b"signed").into(); |
|
| 633 |
+ assert_eq!(hashes[0].sha256, expected_sha256); |
|
| 634 |
+ |
|
| 635 |
+ fs::remove_dir_all(test_root).unwrap(); |
|
| 636 |
+ } |
|
| 637 |
+ |
|
| 638 |
+ #[test] |
|
| 639 |
+ fn parent_seals_nested_bundle_code_resources() {
|
|
| 640 |
+ let test_root = std::env::temp_dir().join(format!( |
|
| 641 |
+ "apple-codesign-nested-resources-{}-{}",
|
|
| 642 |
+ std::process::id(), |
|
| 643 |
+ std::time::SystemTime::now() |
|
| 644 |
+ .duration_since(std::time::UNIX_EPOCH) |
|
| 645 |
+ .unwrap() |
|
| 646 |
+ .as_nanos() |
|
| 647 |
+ )); |
|
| 648 |
+ let child_path = test_root.join("Frameworks/Example.framework");
|
|
| 649 |
+ let signature_path = child_path.join(CODE_SIGNATURE_DIR); |
|
| 650 |
+ fs::create_dir_all(&signature_path).unwrap(); |
|
| 651 |
+ fs::write(child_path.join("Example"), b"executable").unwrap();
|
|
| 652 |
+ fs::write(signature_path.join(CODE_RESOURCES_FILE), b"resources").unwrap(); |
|
| 653 |
+ |
|
| 654 |
+ let mut child_resources = CodeResourcesMaps::default(); |
|
| 655 |
+ child_resources.insert_hash(test_hash("Resource.txt"));
|
|
| 656 |
+ let child = SubBundleResult {
|
|
| 657 |
+ path: child_path, |
|
| 658 |
+ relative_root: "Frameworks/Example.framework".to_string(), |
|
| 659 |
+ executable: "Example".to_string(), |
|
| 660 |
+ resources: child_resources, |
|
| 661 |
+ }; |
|
| 662 |
+ let mut parent = CodeResourcesMaps::default(); |
|
| 663 |
+ |
|
| 664 |
+ merge_sub_bundle_resources(&mut parent, &test_root, &child).unwrap(); |
|
| 665 |
+ |
|
| 666 |
+ assert!( |
|
| 667 |
+ parent |
|
| 668 |
+ .files2 |
|
| 669 |
+ .contains_key("Frameworks/Example.framework/Resource.txt")
|
|
| 670 |
+ ); |
|
| 671 |
+ assert!( |
|
| 672 |
+ parent |
|
| 673 |
+ .files2 |
|
| 674 |
+ .contains_key("Frameworks/Example.framework/Example")
|
|
| 675 |
+ ); |
|
| 676 |
+ assert!( |
|
| 677 |
+ parent |
|
| 678 |
+ .files2 |
|
| 679 |
+ .contains_key("Frameworks/Example.framework/_CodeSignature/CodeResources")
|
|
| 680 |
+ ); |
|
| 681 |
+ |
|
| 682 |
+ fs::remove_dir_all(test_root).unwrap(); |
|
| 683 |
+ } |
|
| 684 |
+} |
| ... | ... |
@@ -0,0 +1,72 @@ |
| 1 |
+use std::path::PathBuf; |
|
| 2 |
+ |
|
| 3 |
+#[derive(Debug, thiserror::Error)] |
|
| 4 |
+pub enum CodeSignError {
|
|
| 5 |
+ #[error("I/O error for {path}: {source}")]
|
|
| 6 |
+ Io {
|
|
| 7 |
+ path: PathBuf, |
|
| 8 |
+ #[source] |
|
| 9 |
+ source: std::io::Error, |
|
| 10 |
+ }, |
|
| 11 |
+ |
|
| 12 |
+ #[error("plist error: {0}")]
|
|
| 13 |
+ Plist(#[from] plist::Error), |
|
| 14 |
+ |
|
| 15 |
+ #[error("bundle is missing Info.plist: {0}")]
|
|
| 16 |
+ MissingInfoPlist(PathBuf), |
|
| 17 |
+ |
|
| 18 |
+ #[error("Info.plist is missing string key {0}")]
|
|
| 19 |
+ MissingInfoString(&'static str), |
|
| 20 |
+ |
|
| 21 |
+ #[error("invalid UTF-8 path inside bundle: {0}")]
|
|
| 22 |
+ InvalidBundlePath(PathBuf), |
|
| 23 |
+ |
|
| 24 |
+ #[error("argument error: {0}")]
|
|
| 25 |
+ Argument(String), |
|
| 26 |
+ |
|
| 27 |
+ #[error("Mach-O error in {path}: {message}")]
|
|
| 28 |
+ MachO { path: PathBuf, message: String },
|
|
| 29 |
+ |
|
| 30 |
+ #[error( |
|
| 31 |
+ "Mach-O in {path} has no safe header padding for a new LC_CODE_SIGNATURE load command; requested signature reservation is {signature_len} bytes"
|
|
| 32 |
+ )] |
|
| 33 |
+ NeedsCodeSignatureAllocation { path: PathBuf, signature_len: usize },
|
|
| 34 |
+ |
|
| 35 |
+ #[error("unsupported entitlement value for key {0}")]
|
|
| 36 |
+ UnsupportedEntitlement(String), |
|
| 37 |
+ |
|
| 38 |
+ #[error("CMS signing error: {0}")]
|
|
| 39 |
+ Cms(String), |
|
| 40 |
+ |
|
| 41 |
+ #[error("provisioning profile error: {0}")]
|
|
| 42 |
+ ProvisioningProfile(String), |
|
| 43 |
+ |
|
| 44 |
+ #[error("CMS signature blob is {actual} bytes, exceeding the {reserved} byte reservation")]
|
|
| 45 |
+ SignatureTooLarge { actual: usize, reserved: usize },
|
|
| 46 |
+} |
|
| 47 |
+ |
|
| 48 |
+impl CodeSignError {
|
|
| 49 |
+ pub(crate) fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
|
|
| 50 |
+ Self::Io {
|
|
| 51 |
+ path: path.into(), |
|
| 52 |
+ source, |
|
| 53 |
+ } |
|
| 54 |
+ } |
|
| 55 |
+ |
|
| 56 |
+ pub(crate) fn macho(path: impl Into<PathBuf>, message: impl Into<String>) -> Self {
|
|
| 57 |
+ Self::MachO {
|
|
| 58 |
+ path: path.into(), |
|
| 59 |
+ message: message.into(), |
|
| 60 |
+ } |
|
| 61 |
+ } |
|
| 62 |
+ |
|
| 63 |
+ pub(crate) fn cms(message: impl Into<String>) -> Self {
|
|
| 64 |
+ Self::Cms(message.into()) |
|
| 65 |
+ } |
|
| 66 |
+ |
|
| 67 |
+ pub(crate) fn provisioning_profile(message: impl Into<String>) -> Self {
|
|
| 68 |
+ Self::ProvisioningProfile(message.into()) |
|
| 69 |
+ } |
|
| 70 |
+} |
|
| 71 |
+ |
|
| 72 |
+pub type Result<T> = std::result::Result<T, CodeSignError>; |
| ... | ... |
@@ -0,0 +1,44 @@ |
| 1 |
+use crate::error::{CodeSignError, Result};
|
|
| 2 |
+use memmap2::{Mmap, MmapOptions};
|
|
| 3 |
+use std::fs; |
|
| 4 |
+use std::path::Path; |
|
| 5 |
+ |
|
| 6 |
+pub(crate) enum FileBytes {
|
|
| 7 |
+ Mapped(Mmap), |
|
| 8 |
+ Owned(Vec<u8>), |
|
| 9 |
+} |
|
| 10 |
+ |
|
| 11 |
+impl FileBytes {
|
|
| 12 |
+ pub(crate) fn as_slice(&self) -> &[u8] {
|
|
| 13 |
+ match self {
|
|
| 14 |
+ Self::Mapped(map) => map, |
|
| 15 |
+ Self::Owned(bytes) => bytes, |
|
| 16 |
+ } |
|
| 17 |
+ } |
|
| 18 |
+} |
|
| 19 |
+ |
|
| 20 |
+pub(crate) fn read_file_bytes(path: &Path) -> Result<FileBytes> {
|
|
| 21 |
+ let file = fs::File::open(path).map_err(|source| CodeSignError::io(path, source))?; |
|
| 22 |
+ let len = file |
|
| 23 |
+ .metadata() |
|
| 24 |
+ .map_err(|source| CodeSignError::io(path, source))? |
|
| 25 |
+ .len(); |
|
| 26 |
+ |
|
| 27 |
+ if len == 0 {
|
|
| 28 |
+ return Ok(FileBytes::Owned(Vec::new())); |
|
| 29 |
+ } |
|
| 30 |
+ |
|
| 31 |
+ // SAFETY: every mapped read in this crate is created from a file opened for |
|
| 32 |
+ // read-only use and is only exposed as an immutable byte slice. During our |
|
| 33 |
+ // own signing flow, files are mapped after any planned in-process rewrites |
|
| 34 |
+ // for that file and before we write the signed replacement. Concurrent |
|
| 35 |
+ // external mutation of the bundle while signing is outside the supported |
|
| 36 |
+ // contract, matching the same precondition as the D MmFile implementation. |
|
| 37 |
+ let mapped = unsafe { MmapOptions::new().map(&file) };
|
|
| 38 |
+ match mapped {
|
|
| 39 |
+ Ok(map) => Ok(FileBytes::Mapped(map)), |
|
| 40 |
+ Err(_) => fs::read(path) |
|
| 41 |
+ .map(FileBytes::Owned) |
|
| 42 |
+ .map_err(|source| CodeSignError::io(path, source)), |
|
| 43 |
+ } |
|
| 44 |
+} |
| ... | ... |
@@ -0,0 +1,16 @@ |
| 1 |
+pub mod bundle; |
|
| 2 |
+pub mod error; |
|
| 3 |
+mod file_bytes; |
|
| 4 |
+pub mod macho; |
|
| 5 |
+pub mod provisioning; |
|
| 6 |
+pub mod rustcrypto_cms; |
|
| 7 |
+pub mod signature; |
|
| 8 |
+ |
|
| 9 |
+pub use bundle::{Bundle, BundleSigningSettings, CodeResourcesMaps, sign_bundle};
|
|
| 10 |
+pub use error::{CodeSignError, Result};
|
|
| 11 |
+pub use macho::{
|
|
| 12 |
+ DEFAULT_CMS_BLOB_RESERVATION, MachOSigningConfig, sign_macho_data, sign_macho_file, |
|
| 13 |
+}; |
|
| 14 |
+pub use provisioning::ProvisioningProfile; |
|
| 15 |
+pub use rustcrypto_cms::RustCryptoCmsSigner; |
|
| 16 |
+pub use signature::{CmsSigner, CmsSigningRequest, EncodedCodeDirectory, HashAlgorithm};
|
| ... | ... |
@@ -0,0 +1,1175 @@ |
| 1 |
+use crate::error::{CodeSignError, Result};
|
|
| 2 |
+use crate::file_bytes::read_file_bytes; |
|
| 3 |
+use crate::signature::{
|
|
| 4 |
+ CmsSigner, CodeDirectoryParams, ExecutableSegment, SuperblobSizePlan, der_entitlements_blob, |
|
| 5 |
+ empty_requirements_blob, encode_embedded_signature, entitlement_exec_flags, entitlements_blob, |
|
| 6 |
+ entitlements_xml, planned_superblob_len_from_parts, |
|
| 7 |
+}; |
|
| 8 |
+use byteorder::{BigEndian, ByteOrder, LittleEndian};
|
|
| 9 |
+use plist::Dictionary; |
|
| 10 |
+use rayon::prelude::*; |
|
| 11 |
+use std::fs; |
|
| 12 |
+use std::path::{Path, PathBuf};
|
|
| 13 |
+ |
|
| 14 |
+const MH_MAGIC: u32 = 0xfeed_face; |
|
| 15 |
+const MH_MAGIC_64: u32 = 0xfeed_facf; |
|
| 16 |
+const MH_EXECUTE: u32 = 0x2; |
|
| 17 |
+ |
|
| 18 |
+const FAT_MAGIC: u32 = 0xcafe_babe; |
|
| 19 |
+const FAT_MAGIC_64: u32 = 0xcafe_babf; |
|
| 20 |
+ |
|
| 21 |
+const LC_SEGMENT: u32 = 0x1; |
|
| 22 |
+const LC_SEGMENT_64: u32 = 0x19; |
|
| 23 |
+const LC_CODE_SIGNATURE: u32 = 0x1d; |
|
| 24 |
+ |
|
| 25 |
+const LINKEDIT_DATA_COMMAND_SIZE: usize = 16; |
|
| 26 |
+const FAT_HEADER_SIZE: usize = 8; |
|
| 27 |
+const FAT_ARCH_SIZE: usize = 20; |
|
| 28 |
+const PAGE_SIZE: usize = 16 * 1024; |
|
| 29 |
+const CODE_SIGNATURE_ALIGNMENT: usize = 16; |
|
| 30 |
+const SECTION_TYPE: u32 = 0xff; |
|
| 31 |
+const S_ZEROFILL: u32 = 0x1; |
|
| 32 |
+const S_THREAD_LOCAL_ZEROFILL: u32 = 0x11; |
|
| 33 |
+ |
|
| 34 |
+pub const DEFAULT_CMS_BLOB_RESERVATION: usize = 16 * 1024; |
|
| 35 |
+ |
|
| 36 |
+#[derive(Clone)] |
|
| 37 |
+pub struct MachOSigningConfig<'a> {
|
|
| 38 |
+ pub identifier: &'a str, |
|
| 39 |
+ pub team_id: &'a str, |
|
| 40 |
+ pub entitlements: &'a Dictionary, |
|
| 41 |
+ pub info_plist: Option<&'a [u8]>, |
|
| 42 |
+ pub code_resources: Option<&'a [u8]>, |
|
| 43 |
+ pub cms_signer: Option<&'a dyn CmsSigner>, |
|
| 44 |
+ pub cms_blob_reservation: usize, |
|
| 45 |
+} |
|
| 46 |
+ |
|
| 47 |
+impl<'a> MachOSigningConfig<'a> {
|
|
| 48 |
+ pub fn new( |
|
| 49 |
+ identifier: &'a str, |
|
| 50 |
+ team_id: &'a str, |
|
| 51 |
+ entitlements: &'a Dictionary, |
|
| 52 |
+ cms_signer: Option<&'a dyn CmsSigner>, |
|
| 53 |
+ ) -> Self {
|
|
| 54 |
+ Self {
|
|
| 55 |
+ identifier, |
|
| 56 |
+ team_id, |
|
| 57 |
+ entitlements, |
|
| 58 |
+ info_plist: None, |
|
| 59 |
+ code_resources: None, |
|
| 60 |
+ cms_signer, |
|
| 61 |
+ cms_blob_reservation: DEFAULT_CMS_BLOB_RESERVATION, |
|
| 62 |
+ } |
|
| 63 |
+ } |
|
| 64 |
+} |
|
| 65 |
+ |
|
| 66 |
+pub fn sign_macho_file(path: &Path, config: &MachOSigningConfig<'_>) -> Result<()> {
|
|
| 67 |
+ let original = read_file_bytes(path)?; |
|
| 68 |
+ let signed = sign_macho_data(path, original.as_slice(), config)?; |
|
| 69 |
+ fs::write(path, signed).map_err(|source| CodeSignError::io(path, source))?; |
|
| 70 |
+ Ok(()) |
|
| 71 |
+} |
|
| 72 |
+ |
|
| 73 |
+pub fn sign_macho_data( |
|
| 74 |
+ path: &Path, |
|
| 75 |
+ data: &[u8], |
|
| 76 |
+ config: &MachOSigningConfig<'_>, |
|
| 77 |
+) -> Result<Vec<u8>> {
|
|
| 78 |
+ sign_macho_owned(path, data.to_vec(), config) |
|
| 79 |
+} |
|
| 80 |
+ |
|
| 81 |
+fn sign_macho_owned( |
|
| 82 |
+ path: &Path, |
|
| 83 |
+ data: Vec<u8>, |
|
| 84 |
+ config: &MachOSigningConfig<'_>, |
|
| 85 |
+) -> Result<Vec<u8>> {
|
|
| 86 |
+ if let Some(fat_arches) = parse_fat_arches(path, &data)? {
|
|
| 87 |
+ let signed_arches = fat_arches |
|
| 88 |
+ .par_iter() |
|
| 89 |
+ .map(|arch| {
|
|
| 90 |
+ let bytes = data[arch.offset..arch.offset + arch.size].to_vec(); |
|
| 91 |
+ let signed = sign_thin_macho(path, bytes, config)?; |
|
| 92 |
+ Ok(SignedArch {
|
|
| 93 |
+ cputype: signed.cputype, |
|
| 94 |
+ cpusubtype: signed.cpusubtype, |
|
| 95 |
+ align: arch.align, |
|
| 96 |
+ data: signed.data, |
|
| 97 |
+ }) |
|
| 98 |
+ }) |
|
| 99 |
+ .collect::<Result<Vec<_>>>()?; |
|
| 100 |
+ |
|
| 101 |
+ rebuild_fat(path, &signed_arches) |
|
| 102 |
+ } else {
|
|
| 103 |
+ Ok(sign_thin_macho(path, data, config)?.data) |
|
| 104 |
+ } |
|
| 105 |
+} |
|
| 106 |
+ |
|
| 107 |
+fn sign_thin_macho( |
|
| 108 |
+ path: &Path, |
|
| 109 |
+ data: Vec<u8>, |
|
| 110 |
+ config: &MachOSigningConfig<'_>, |
|
| 111 |
+) -> Result<SignedThin> {
|
|
| 112 |
+ let mut macho = ThinMachO::parse(path, data)?; |
|
| 113 |
+ |
|
| 114 |
+ let requirements = empty_requirements_blob(); |
|
| 115 |
+ let entitlements_xml = entitlements_xml(config.entitlements)?; |
|
| 116 |
+ let entitlements = entitlements_blob(&entitlements_xml); |
|
| 117 |
+ let der_entitlements = if macho.is_executable() {
|
|
| 118 |
+ Some(der_entitlements_blob(config.entitlements)?) |
|
| 119 |
+ } else {
|
|
| 120 |
+ None |
|
| 121 |
+ }; |
|
| 122 |
+ |
|
| 123 |
+ let mut cms_blob_reservation = config.cms_blob_reservation; |
|
| 124 |
+ |
|
| 125 |
+ for _ in 0..8 {
|
|
| 126 |
+ let code_limit = macho.code_signature_offset().unwrap_or(macho.data.len()); |
|
| 127 |
+ let planned_len = planned_superblob_len_from_parts(SuperblobSizePlan {
|
|
| 128 |
+ identifier: config.identifier, |
|
| 129 |
+ team_id: config.team_id, |
|
| 130 |
+ code_limit, |
|
| 131 |
+ is_executable: macho.is_executable(), |
|
| 132 |
+ requirements_blob_len: requirements.len(), |
|
| 133 |
+ entitlements_blob_len: entitlements.len(), |
|
| 134 |
+ der_entitlements_blob_len: der_entitlements.as_ref().map(Vec::len), |
|
| 135 |
+ has_cms_signature: config.cms_signer.is_some(), |
|
| 136 |
+ cms_blob_reservation, |
|
| 137 |
+ }); |
|
| 138 |
+ |
|
| 139 |
+ macho.reserve_code_signature(planned_len)?; |
|
| 140 |
+ |
|
| 141 |
+ let code_limit = macho |
|
| 142 |
+ .code_signature_offset() |
|
| 143 |
+ .ok_or_else(|| CodeSignError::macho(path, "missing LC_CODE_SIGNATURE after reserve"))?; |
|
| 144 |
+ let exec_flags = entitlement_exec_flags(config.entitlements, macho.is_executable()); |
|
| 145 |
+ let params = CodeDirectoryParams {
|
|
| 146 |
+ identifier: config.identifier, |
|
| 147 |
+ team_id: config.team_id, |
|
| 148 |
+ macho_data: &macho.data, |
|
| 149 |
+ code_limit, |
|
| 150 |
+ executable_segment: ExecutableSegment {
|
|
| 151 |
+ base: macho.exec_segment.base, |
|
| 152 |
+ limit: macho.exec_segment.limit, |
|
| 153 |
+ flags: exec_flags, |
|
| 154 |
+ }, |
|
| 155 |
+ is_executable: macho.is_executable(), |
|
| 156 |
+ requirements_blob: &requirements, |
|
| 157 |
+ entitlements_blob: &entitlements, |
|
| 158 |
+ der_entitlements_blob: der_entitlements.as_deref(), |
|
| 159 |
+ info_plist: config.info_plist, |
|
| 160 |
+ code_resources: config.code_resources, |
|
| 161 |
+ }; |
|
| 162 |
+ |
|
| 163 |
+ match encode_embedded_signature(¶ms, config.cms_signer, cms_blob_reservation) {
|
|
| 164 |
+ Ok(signature) => {
|
|
| 165 |
+ macho.write_code_signature(&signature)?; |
|
| 166 |
+ return Ok(SignedThin {
|
|
| 167 |
+ cputype: macho.cputype, |
|
| 168 |
+ cpusubtype: macho.cpusubtype, |
|
| 169 |
+ data: macho.data, |
|
| 170 |
+ }); |
|
| 171 |
+ } |
|
| 172 |
+ Err(CodeSignError::SignatureTooLarge { actual, .. }) => {
|
|
| 173 |
+ cms_blob_reservation = next_reservation(cms_blob_reservation, actual); |
|
| 174 |
+ } |
|
| 175 |
+ Err(err) => return Err(err), |
|
| 176 |
+ } |
|
| 177 |
+ } |
|
| 178 |
+ |
|
| 179 |
+ Err(CodeSignError::macho( |
|
| 180 |
+ path, |
|
| 181 |
+ "CMS signature did not fit after repeated reservation growth", |
|
| 182 |
+ )) |
|
| 183 |
+} |
|
| 184 |
+ |
|
| 185 |
+fn next_reservation(current: usize, actual: usize) -> usize {
|
|
| 186 |
+ let doubled = current.saturating_mul(2).max(DEFAULT_CMS_BLOB_RESERVATION); |
|
| 187 |
+ align_to(doubled.max(actual), 4096) |
|
| 188 |
+} |
|
| 189 |
+ |
|
| 190 |
+#[derive(Debug)] |
|
| 191 |
+struct ThinMachO {
|
|
| 192 |
+ path: PathBuf, |
|
| 193 |
+ data: Vec<u8>, |
|
| 194 |
+ cputype: i32, |
|
| 195 |
+ cpusubtype: i32, |
|
| 196 |
+ filetype: u32, |
|
| 197 |
+ ncmds: u32, |
|
| 198 |
+ sizeofcmds: u32, |
|
| 199 |
+ header_size: usize, |
|
| 200 |
+ code_signature_command_offset: Option<usize>, |
|
| 201 |
+ linkedit_command_offset: Option<usize>, |
|
| 202 |
+ header_pad_limit: usize, |
|
| 203 |
+ exec_segment: SegmentBounds, |
|
| 204 |
+} |
|
| 205 |
+ |
|
| 206 |
+#[derive(Clone, Copy, Debug, Default)] |
|
| 207 |
+struct SegmentBounds {
|
|
| 208 |
+ base: u64, |
|
| 209 |
+ limit: u64, |
|
| 210 |
+} |
|
| 211 |
+ |
|
| 212 |
+impl ThinMachO {
|
|
| 213 |
+ fn parse(path: &Path, data: Vec<u8>) -> Result<Self> {
|
|
| 214 |
+ ensure_len(path, &data, 28, "Mach-O header")?; |
|
| 215 |
+ |
|
| 216 |
+ let magic = LittleEndian::read_u32(&data[0..4]); |
|
| 217 |
+ let is_64 = match magic {
|
|
| 218 |
+ MH_MAGIC => false, |
|
| 219 |
+ MH_MAGIC_64 => true, |
|
| 220 |
+ _ => {
|
|
| 221 |
+ return Err(CodeSignError::macho( |
|
| 222 |
+ path, |
|
| 223 |
+ format!("unsupported Mach-O magic 0x{magic:08x}"),
|
|
| 224 |
+ )); |
|
| 225 |
+ } |
|
| 226 |
+ }; |
|
| 227 |
+ |
|
| 228 |
+ let header_size = if is_64 { 32 } else { 28 };
|
|
| 229 |
+ ensure_len(path, &data, header_size, "Mach-O header")?; |
|
| 230 |
+ |
|
| 231 |
+ let cputype = LittleEndian::read_i32(&data[4..8]); |
|
| 232 |
+ let cpusubtype = LittleEndian::read_i32(&data[8..12]); |
|
| 233 |
+ let filetype = LittleEndian::read_u32(&data[12..16]); |
|
| 234 |
+ let ncmds = LittleEndian::read_u32(&data[16..20]); |
|
| 235 |
+ let sizeofcmds = LittleEndian::read_u32(&data[20..24]); |
|
| 236 |
+ let commands_end = checked_add(path, header_size, sizeofcmds as usize, "load commands")?; |
|
| 237 |
+ ensure_len(path, &data, commands_end, "load commands")?; |
|
| 238 |
+ |
|
| 239 |
+ let mut offset = header_size; |
|
| 240 |
+ let mut code_signature_command_offset = None; |
|
| 241 |
+ let mut linkedit_command_offset = None; |
|
| 242 |
+ let mut first_section_offset = None; |
|
| 243 |
+ let mut exec_segment = SegmentBounds::default(); |
|
| 244 |
+ |
|
| 245 |
+ for _ in 0..ncmds {
|
|
| 246 |
+ ensure_len(path, &data, offset + 8, "load command")?; |
|
| 247 |
+ let cmd = LittleEndian::read_u32(&data[offset..offset + 4]); |
|
| 248 |
+ let cmdsize = LittleEndian::read_u32(&data[offset + 4..offset + 8]) as usize; |
|
| 249 |
+ if cmdsize < 8 {
|
|
| 250 |
+ return Err(CodeSignError::macho( |
|
| 251 |
+ path, |
|
| 252 |
+ "load command is smaller than 8 bytes", |
|
| 253 |
+ )); |
|
| 254 |
+ } |
|
| 255 |
+ let command_end = checked_add(path, offset, cmdsize, "load command")?; |
|
| 256 |
+ if command_end > commands_end {
|
|
| 257 |
+ return Err(CodeSignError::macho( |
|
| 258 |
+ path, |
|
| 259 |
+ "load command extends beyond sizeofcmds", |
|
| 260 |
+ )); |
|
| 261 |
+ } |
|
| 262 |
+ |
|
| 263 |
+ match cmd {
|
|
| 264 |
+ LC_SEGMENT => {
|
|
| 265 |
+ parse_segment_32( |
|
| 266 |
+ path, |
|
| 267 |
+ &data, |
|
| 268 |
+ offset, |
|
| 269 |
+ cmdsize, |
|
| 270 |
+ &mut linkedit_command_offset, |
|
| 271 |
+ &mut exec_segment, |
|
| 272 |
+ &mut first_section_offset, |
|
| 273 |
+ )?; |
|
| 274 |
+ } |
|
| 275 |
+ LC_SEGMENT_64 => {
|
|
| 276 |
+ parse_segment_64( |
|
| 277 |
+ path, |
|
| 278 |
+ &data, |
|
| 279 |
+ offset, |
|
| 280 |
+ cmdsize, |
|
| 281 |
+ &mut linkedit_command_offset, |
|
| 282 |
+ &mut exec_segment, |
|
| 283 |
+ &mut first_section_offset, |
|
| 284 |
+ )?; |
|
| 285 |
+ } |
|
| 286 |
+ LC_CODE_SIGNATURE => {
|
|
| 287 |
+ if cmdsize < LINKEDIT_DATA_COMMAND_SIZE {
|
|
| 288 |
+ return Err(CodeSignError::macho(path, "LC_CODE_SIGNATURE is too small")); |
|
| 289 |
+ } |
|
| 290 |
+ code_signature_command_offset = Some(offset); |
|
| 291 |
+ } |
|
| 292 |
+ _ => {}
|
|
| 293 |
+ } |
|
| 294 |
+ |
|
| 295 |
+ offset = command_end; |
|
| 296 |
+ } |
|
| 297 |
+ |
|
| 298 |
+ let header_pad_limit = first_section_offset |
|
| 299 |
+ .filter(|offset| *offset > commands_end) |
|
| 300 |
+ .unwrap_or(data.len()) |
|
| 301 |
+ .min(data.len()); |
|
| 302 |
+ |
|
| 303 |
+ Ok(Self {
|
|
| 304 |
+ path: path.to_path_buf(), |
|
| 305 |
+ data, |
|
| 306 |
+ cputype, |
|
| 307 |
+ cpusubtype, |
|
| 308 |
+ filetype, |
|
| 309 |
+ ncmds, |
|
| 310 |
+ sizeofcmds, |
|
| 311 |
+ header_size, |
|
| 312 |
+ code_signature_command_offset, |
|
| 313 |
+ linkedit_command_offset, |
|
| 314 |
+ header_pad_limit, |
|
| 315 |
+ exec_segment, |
|
| 316 |
+ }) |
|
| 317 |
+ } |
|
| 318 |
+ |
|
| 319 |
+ fn is_executable(&self) -> bool {
|
|
| 320 |
+ self.filetype == MH_EXECUTE |
|
| 321 |
+ } |
|
| 322 |
+ |
|
| 323 |
+ fn code_signature_offset(&self) -> Option<usize> {
|
|
| 324 |
+ self.code_signature_command_offset |
|
| 325 |
+ .map(|offset| LittleEndian::read_u32(&self.data[offset + 8..offset + 12]) as usize) |
|
| 326 |
+ } |
|
| 327 |
+ |
|
| 328 |
+ fn reserve_code_signature(&mut self, signature_len: usize) -> Result<()> {
|
|
| 329 |
+ if self.code_signature_command_offset.is_none() {
|
|
| 330 |
+ self.install_code_signature_command(signature_len)?; |
|
| 331 |
+ } |
|
| 332 |
+ |
|
| 333 |
+ let command_offset = self |
|
| 334 |
+ .code_signature_command_offset |
|
| 335 |
+ .ok_or_else(|| CodeSignError::macho(&self.path, "missing LC_CODE_SIGNATURE"))?; |
|
| 336 |
+ let dataoff = |
|
| 337 |
+ LittleEndian::read_u32(&self.data[command_offset + 8..command_offset + 12]) as usize; |
|
| 338 |
+ let datasize = |
|
| 339 |
+ LittleEndian::read_u32(&self.data[command_offset + 12..command_offset + 16]) as usize; |
|
| 340 |
+ |
|
| 341 |
+ if datasize == 0 {
|
|
| 342 |
+ return self.allocate_code_signature(command_offset, signature_len); |
|
| 343 |
+ } |
|
| 344 |
+ |
|
| 345 |
+ if signature_len <= datasize {
|
|
| 346 |
+ self.zero_signature_region(dataoff, datasize)?; |
|
| 347 |
+ return Ok(()); |
|
| 348 |
+ } |
|
| 349 |
+ |
|
| 350 |
+ let old_end = checked_add(&self.path, dataoff, datasize, "code signature")?; |
|
| 351 |
+ if old_end < self.data.len() {
|
|
| 352 |
+ return Err(CodeSignError::macho( |
|
| 353 |
+ &self.path, |
|
| 354 |
+ "cannot grow a code signature that is not at the end of the Mach-O slice", |
|
| 355 |
+ )); |
|
| 356 |
+ } |
|
| 357 |
+ |
|
| 358 |
+ let linkedit_offset = self.linkedit_command_offset.ok_or_else(|| {
|
|
| 359 |
+ CodeSignError::macho(&self.path, "missing __LINKEDIT segment for code signature") |
|
| 360 |
+ })?; |
|
| 361 |
+ |
|
| 362 |
+ let extra_file_size = signature_len - datasize; |
|
| 363 |
+ let extra_vm_size = page_ceil(signature_len).saturating_sub(page_ceil(datasize)); |
|
| 364 |
+ self.grow_linkedit_segment(linkedit_offset, extra_file_size, extra_vm_size)?; |
|
| 365 |
+ |
|
| 366 |
+ self.data.truncate(dataoff); |
|
| 367 |
+ self.data.resize(dataoff + signature_len, 0); |
|
| 368 |
+ LittleEndian::write_u32( |
|
| 369 |
+ &mut self.data[command_offset + 8..command_offset + 12], |
|
| 370 |
+ dataoff.try_into().map_err(|_| {
|
|
| 371 |
+ CodeSignError::macho(&self.path, "code signature offset exceeds u32") |
|
| 372 |
+ })?, |
|
| 373 |
+ ); |
|
| 374 |
+ LittleEndian::write_u32( |
|
| 375 |
+ &mut self.data[command_offset + 12..command_offset + 16], |
|
| 376 |
+ signature_len |
|
| 377 |
+ .try_into() |
|
| 378 |
+ .map_err(|_| CodeSignError::macho(&self.path, "code signature size exceeds u32"))?, |
|
| 379 |
+ ); |
|
| 380 |
+ |
|
| 381 |
+ Ok(()) |
|
| 382 |
+ } |
|
| 383 |
+ |
|
| 384 |
+ fn install_code_signature_command(&mut self, signature_len: usize) -> Result<()> {
|
|
| 385 |
+ let command_offset = self.header_size + self.sizeofcmds as usize; |
|
| 386 |
+ let command_end = command_offset + LINKEDIT_DATA_COMMAND_SIZE; |
|
| 387 |
+ |
|
| 388 |
+ if command_end > self.header_pad_limit {
|
|
| 389 |
+ return Err(CodeSignError::NeedsCodeSignatureAllocation {
|
|
| 390 |
+ path: self.path.clone(), |
|
| 391 |
+ signature_len, |
|
| 392 |
+ }); |
|
| 393 |
+ } |
|
| 394 |
+ |
|
| 395 |
+ if command_end > self.data.len() {
|
|
| 396 |
+ return Err(CodeSignError::macho( |
|
| 397 |
+ &self.path, |
|
| 398 |
+ "Mach-O ended before available load command padding", |
|
| 399 |
+ )); |
|
| 400 |
+ } |
|
| 401 |
+ |
|
| 402 |
+ if self.data[command_offset..command_end] |
|
| 403 |
+ .iter() |
|
| 404 |
+ .any(|byte| *byte != 0) |
|
| 405 |
+ {
|
|
| 406 |
+ return Err(CodeSignError::NeedsCodeSignatureAllocation {
|
|
| 407 |
+ path: self.path.clone(), |
|
| 408 |
+ signature_len, |
|
| 409 |
+ }); |
|
| 410 |
+ } |
|
| 411 |
+ |
|
| 412 |
+ LittleEndian::write_u32( |
|
| 413 |
+ &mut self.data[command_offset..command_offset + 4], |
|
| 414 |
+ LC_CODE_SIGNATURE, |
|
| 415 |
+ ); |
|
| 416 |
+ LittleEndian::write_u32( |
|
| 417 |
+ &mut self.data[command_offset + 4..command_offset + 8], |
|
| 418 |
+ LINKEDIT_DATA_COMMAND_SIZE as u32, |
|
| 419 |
+ ); |
|
| 420 |
+ LittleEndian::write_u32(&mut self.data[command_offset + 8..command_offset + 12], 0); |
|
| 421 |
+ LittleEndian::write_u32(&mut self.data[command_offset + 12..command_offset + 16], 0); |
|
| 422 |
+ |
|
| 423 |
+ self.ncmds += 1; |
|
| 424 |
+ self.sizeofcmds += LINKEDIT_DATA_COMMAND_SIZE as u32; |
|
| 425 |
+ LittleEndian::write_u32(&mut self.data[16..20], self.ncmds); |
|
| 426 |
+ LittleEndian::write_u32(&mut self.data[20..24], self.sizeofcmds); |
|
| 427 |
+ self.code_signature_command_offset = Some(command_offset); |
|
| 428 |
+ |
|
| 429 |
+ Ok(()) |
|
| 430 |
+ } |
|
| 431 |
+ |
|
| 432 |
+ fn allocate_code_signature( |
|
| 433 |
+ &mut self, |
|
| 434 |
+ command_offset: usize, |
|
| 435 |
+ signature_len: usize, |
|
| 436 |
+ ) -> Result<()> {
|
|
| 437 |
+ let linkedit_offset = self.linkedit_command_offset.ok_or_else(|| {
|
|
| 438 |
+ CodeSignError::macho(&self.path, "missing __LINKEDIT segment for code signature") |
|
| 439 |
+ })?; |
|
| 440 |
+ let linkedit_end = self.linkedit_file_end(linkedit_offset)?; |
|
| 441 |
+ if linkedit_end > self.data.len() {
|
|
| 442 |
+ return Err(CodeSignError::macho( |
|
| 443 |
+ &self.path, |
|
| 444 |
+ "__LINKEDIT extends beyond the Mach-O slice", |
|
| 445 |
+ )); |
|
| 446 |
+ } |
|
| 447 |
+ |
|
| 448 |
+ let dataoff = align_to(linkedit_end, CODE_SIGNATURE_ALIGNMENT); |
|
| 449 |
+ let padding = dataoff - linkedit_end; |
|
| 450 |
+ self.resize_linkedit_for_new_signature(linkedit_offset, padding, signature_len)?; |
|
| 451 |
+ |
|
| 452 |
+ self.data.truncate(linkedit_end); |
|
| 453 |
+ self.data.resize(dataoff + signature_len, 0); |
|
| 454 |
+ |
|
| 455 |
+ LittleEndian::write_u32( |
|
| 456 |
+ &mut self.data[command_offset + 8..command_offset + 12], |
|
| 457 |
+ dataoff.try_into().map_err(|_| {
|
|
| 458 |
+ CodeSignError::macho(&self.path, "code signature offset exceeds u32") |
|
| 459 |
+ })?, |
|
| 460 |
+ ); |
|
| 461 |
+ LittleEndian::write_u32( |
|
| 462 |
+ &mut self.data[command_offset + 12..command_offset + 16], |
|
| 463 |
+ signature_len |
|
| 464 |
+ .try_into() |
|
| 465 |
+ .map_err(|_| CodeSignError::macho(&self.path, "code signature size exceeds u32"))?, |
|
| 466 |
+ ); |
|
| 467 |
+ |
|
| 468 |
+ Ok(()) |
|
| 469 |
+ } |
|
| 470 |
+ |
|
| 471 |
+ fn write_code_signature(&mut self, signature: &[u8]) -> Result<()> {
|
|
| 472 |
+ let command_offset = self |
|
| 473 |
+ .code_signature_command_offset |
|
| 474 |
+ .ok_or_else(|| CodeSignError::macho(&self.path, "missing LC_CODE_SIGNATURE"))?; |
|
| 475 |
+ let dataoff = |
|
| 476 |
+ LittleEndian::read_u32(&self.data[command_offset + 8..command_offset + 12]) as usize; |
|
| 477 |
+ let datasize = |
|
| 478 |
+ LittleEndian::read_u32(&self.data[command_offset + 12..command_offset + 16]) as usize; |
|
| 479 |
+ |
|
| 480 |
+ if signature.len() > datasize {
|
|
| 481 |
+ return Err(CodeSignError::macho( |
|
| 482 |
+ &self.path, |
|
| 483 |
+ "encoded signature exceeded reserved LC_CODE_SIGNATURE size", |
|
| 484 |
+ )); |
|
| 485 |
+ } |
|
| 486 |
+ |
|
| 487 |
+ let end = checked_add(&self.path, dataoff, datasize, "code signature")?; |
|
| 488 |
+ ensure_len(&self.path, &self.data, end, "code signature")?; |
|
| 489 |
+ self.data[dataoff..dataoff + signature.len()].copy_from_slice(signature); |
|
| 490 |
+ self.data[dataoff + signature.len()..end].fill(0); |
|
| 491 |
+ Ok(()) |
|
| 492 |
+ } |
|
| 493 |
+ |
|
| 494 |
+ fn zero_signature_region(&mut self, dataoff: usize, datasize: usize) -> Result<()> {
|
|
| 495 |
+ let end = checked_add(&self.path, dataoff, datasize, "code signature")?; |
|
| 496 |
+ ensure_len(&self.path, &self.data, end, "code signature")?; |
|
| 497 |
+ self.data[dataoff..end].fill(0); |
|
| 498 |
+ Ok(()) |
|
| 499 |
+ } |
|
| 500 |
+ |
|
| 501 |
+ fn grow_linkedit_segment( |
|
| 502 |
+ &mut self, |
|
| 503 |
+ command_offset: usize, |
|
| 504 |
+ extra_file_size: usize, |
|
| 505 |
+ extra_vm_size: usize, |
|
| 506 |
+ ) -> Result<()> {
|
|
| 507 |
+ let cmd = LittleEndian::read_u32(&self.data[command_offset..command_offset + 4]); |
|
| 508 |
+ match cmd {
|
|
| 509 |
+ LC_SEGMENT => {
|
|
| 510 |
+ let filesize_offset = command_offset + 36; |
|
| 511 |
+ let vmsize_offset = command_offset + 28; |
|
| 512 |
+ let filesize = |
|
| 513 |
+ LittleEndian::read_u32(&self.data[filesize_offset..filesize_offset + 4]) |
|
| 514 |
+ as usize; |
|
| 515 |
+ let vmsize = |
|
| 516 |
+ LittleEndian::read_u32(&self.data[vmsize_offset..vmsize_offset + 4]) as usize; |
|
| 517 |
+ LittleEndian::write_u32( |
|
| 518 |
+ &mut self.data[filesize_offset..filesize_offset + 4], |
|
| 519 |
+ checked_add(&self.path, filesize, extra_file_size, "__LINKEDIT filesize")? |
|
| 520 |
+ .try_into() |
|
| 521 |
+ .map_err(|_| {
|
|
| 522 |
+ CodeSignError::macho(&self.path, "__LINKEDIT filesize exceeds u32") |
|
| 523 |
+ })?, |
|
| 524 |
+ ); |
|
| 525 |
+ LittleEndian::write_u32( |
|
| 526 |
+ &mut self.data[vmsize_offset..vmsize_offset + 4], |
|
| 527 |
+ checked_add(&self.path, vmsize, extra_vm_size, "__LINKEDIT vmsize")? |
|
| 528 |
+ .try_into() |
|
| 529 |
+ .map_err(|_| {
|
|
| 530 |
+ CodeSignError::macho(&self.path, "__LINKEDIT vmsize exceeds u32") |
|
| 531 |
+ })?, |
|
| 532 |
+ ); |
|
| 533 |
+ } |
|
| 534 |
+ LC_SEGMENT_64 => {
|
|
| 535 |
+ let filesize_offset = command_offset + 48; |
|
| 536 |
+ let vmsize_offset = command_offset + 32; |
|
| 537 |
+ let filesize = |
|
| 538 |
+ LittleEndian::read_u64(&self.data[filesize_offset..filesize_offset + 8]); |
|
| 539 |
+ let vmsize = LittleEndian::read_u64(&self.data[vmsize_offset..vmsize_offset + 8]); |
|
| 540 |
+ LittleEndian::write_u64( |
|
| 541 |
+ &mut self.data[filesize_offset..filesize_offset + 8], |
|
| 542 |
+ filesize |
|
| 543 |
+ .checked_add(extra_file_size as u64) |
|
| 544 |
+ .ok_or_else(|| {
|
|
| 545 |
+ CodeSignError::macho(&self.path, "__LINKEDIT filesize overflow") |
|
| 546 |
+ })?, |
|
| 547 |
+ ); |
|
| 548 |
+ LittleEndian::write_u64( |
|
| 549 |
+ &mut self.data[vmsize_offset..vmsize_offset + 8], |
|
| 550 |
+ vmsize.checked_add(extra_vm_size as u64).ok_or_else(|| {
|
|
| 551 |
+ CodeSignError::macho(&self.path, "__LINKEDIT vmsize overflow") |
|
| 552 |
+ })?, |
|
| 553 |
+ ); |
|
| 554 |
+ } |
|
| 555 |
+ _ => {
|
|
| 556 |
+ return Err(CodeSignError::macho( |
|
| 557 |
+ &self.path, |
|
| 558 |
+ "__LINKEDIT command was not a segment command", |
|
| 559 |
+ )); |
|
| 560 |
+ } |
|
| 561 |
+ } |
|
| 562 |
+ |
|
| 563 |
+ Ok(()) |
|
| 564 |
+ } |
|
| 565 |
+ |
|
| 566 |
+ fn linkedit_file_end(&self, command_offset: usize) -> Result<usize> {
|
|
| 567 |
+ let cmd = LittleEndian::read_u32(&self.data[command_offset..command_offset + 4]); |
|
| 568 |
+ match cmd {
|
|
| 569 |
+ LC_SEGMENT => {
|
|
| 570 |
+ let fileoff = |
|
| 571 |
+ LittleEndian::read_u32(&self.data[command_offset + 32..command_offset + 36]) |
|
| 572 |
+ as usize; |
|
| 573 |
+ let filesize = |
|
| 574 |
+ LittleEndian::read_u32(&self.data[command_offset + 36..command_offset + 40]) |
|
| 575 |
+ as usize; |
|
| 576 |
+ checked_add(&self.path, fileoff, filesize, "__LINKEDIT end") |
|
| 577 |
+ } |
|
| 578 |
+ LC_SEGMENT_64 => {
|
|
| 579 |
+ let fileoff = |
|
| 580 |
+ LittleEndian::read_u64(&self.data[command_offset + 40..command_offset + 48]); |
|
| 581 |
+ let filesize = |
|
| 582 |
+ LittleEndian::read_u64(&self.data[command_offset + 48..command_offset + 56]); |
|
| 583 |
+ let end = fileoff |
|
| 584 |
+ .checked_add(filesize) |
|
| 585 |
+ .ok_or_else(|| CodeSignError::macho(&self.path, "__LINKEDIT end overflow"))?; |
|
| 586 |
+ usize::try_from(end) |
|
| 587 |
+ .map_err(|_| CodeSignError::macho(&self.path, "__LINKEDIT end exceeds usize")) |
|
| 588 |
+ } |
|
| 589 |
+ _ => Err(CodeSignError::macho( |
|
| 590 |
+ &self.path, |
|
| 591 |
+ "__LINKEDIT command was not a segment command", |
|
| 592 |
+ )), |
|
| 593 |
+ } |
|
| 594 |
+ } |
|
| 595 |
+ |
|
| 596 |
+ fn resize_linkedit_for_new_signature( |
|
| 597 |
+ &mut self, |
|
| 598 |
+ command_offset: usize, |
|
| 599 |
+ padding: usize, |
|
| 600 |
+ signature_len: usize, |
|
| 601 |
+ ) -> Result<()> {
|
|
| 602 |
+ let extra_file_size = checked_add( |
|
| 603 |
+ &self.path, |
|
| 604 |
+ padding, |
|
| 605 |
+ signature_len, |
|
| 606 |
+ "__LINKEDIT code signature growth", |
|
| 607 |
+ )?; |
|
| 608 |
+ let cmd = LittleEndian::read_u32(&self.data[command_offset..command_offset + 4]); |
|
| 609 |
+ |
|
| 610 |
+ match cmd {
|
|
| 611 |
+ LC_SEGMENT => {
|
|
| 612 |
+ let filesize_offset = command_offset + 36; |
|
| 613 |
+ let vmsize_offset = command_offset + 28; |
|
| 614 |
+ let filesize = |
|
| 615 |
+ LittleEndian::read_u32(&self.data[filesize_offset..filesize_offset + 4]) |
|
| 616 |
+ as usize; |
|
| 617 |
+ let vmsize = |
|
| 618 |
+ LittleEndian::read_u32(&self.data[vmsize_offset..vmsize_offset + 4]) as usize; |
|
| 619 |
+ let new_filesize = |
|
| 620 |
+ checked_add(&self.path, filesize, extra_file_size, "__LINKEDIT filesize")?; |
|
| 621 |
+ let new_vmsize = vmsize.max(page_ceil(new_filesize)); |
|
| 622 |
+ |
|
| 623 |
+ LittleEndian::write_u32( |
|
| 624 |
+ &mut self.data[filesize_offset..filesize_offset + 4], |
|
| 625 |
+ new_filesize.try_into().map_err(|_| {
|
|
| 626 |
+ CodeSignError::macho(&self.path, "__LINKEDIT filesize exceeds u32") |
|
| 627 |
+ })?, |
|
| 628 |
+ ); |
|
| 629 |
+ LittleEndian::write_u32( |
|
| 630 |
+ &mut self.data[vmsize_offset..vmsize_offset + 4], |
|
| 631 |
+ new_vmsize.try_into().map_err(|_| {
|
|
| 632 |
+ CodeSignError::macho(&self.path, "__LINKEDIT vmsize exceeds u32") |
|
| 633 |
+ })?, |
|
| 634 |
+ ); |
|
| 635 |
+ } |
|
| 636 |
+ LC_SEGMENT_64 => {
|
|
| 637 |
+ let filesize_offset = command_offset + 48; |
|
| 638 |
+ let vmsize_offset = command_offset + 32; |
|
| 639 |
+ let filesize = |
|
| 640 |
+ LittleEndian::read_u64(&self.data[filesize_offset..filesize_offset + 8]); |
|
| 641 |
+ let vmsize = LittleEndian::read_u64(&self.data[vmsize_offset..vmsize_offset + 8]); |
|
| 642 |
+ let new_filesize = |
|
| 643 |
+ filesize |
|
| 644 |
+ .checked_add(extra_file_size as u64) |
|
| 645 |
+ .ok_or_else(|| {
|
|
| 646 |
+ CodeSignError::macho(&self.path, "__LINKEDIT filesize overflow") |
|
| 647 |
+ })?; |
|
| 648 |
+ let new_vmsize = vmsize.max(page_ceil_u64(new_filesize)); |
|
| 649 |
+ |
|
| 650 |
+ LittleEndian::write_u64( |
|
| 651 |
+ &mut self.data[filesize_offset..filesize_offset + 8], |
|
| 652 |
+ new_filesize, |
|
| 653 |
+ ); |
|
| 654 |
+ LittleEndian::write_u64( |
|
| 655 |
+ &mut self.data[vmsize_offset..vmsize_offset + 8], |
|
| 656 |
+ new_vmsize, |
|
| 657 |
+ ); |
|
| 658 |
+ } |
|
| 659 |
+ _ => {
|
|
| 660 |
+ return Err(CodeSignError::macho( |
|
| 661 |
+ &self.path, |
|
| 662 |
+ "__LINKEDIT command was not a segment command", |
|
| 663 |
+ )); |
|
| 664 |
+ } |
|
| 665 |
+ } |
|
| 666 |
+ |
|
| 667 |
+ Ok(()) |
|
| 668 |
+ } |
|
| 669 |
+} |
|
| 670 |
+ |
|
| 671 |
+#[derive(Debug)] |
|
| 672 |
+struct SignedThin {
|
|
| 673 |
+ cputype: i32, |
|
| 674 |
+ cpusubtype: i32, |
|
| 675 |
+ data: Vec<u8>, |
|
| 676 |
+} |
|
| 677 |
+ |
|
| 678 |
+#[derive(Clone, Debug)] |
|
| 679 |
+struct FatArch {
|
|
| 680 |
+ offset: usize, |
|
| 681 |
+ size: usize, |
|
| 682 |
+ align: u32, |
|
| 683 |
+} |
|
| 684 |
+ |
|
| 685 |
+#[derive(Debug)] |
|
| 686 |
+struct SignedArch {
|
|
| 687 |
+ cputype: i32, |
|
| 688 |
+ cpusubtype: i32, |
|
| 689 |
+ align: u32, |
|
| 690 |
+ data: Vec<u8>, |
|
| 691 |
+} |
|
| 692 |
+ |
|
| 693 |
+fn parse_fat_arches(path: &Path, data: &[u8]) -> Result<Option<Vec<FatArch>>> {
|
|
| 694 |
+ if data.len() < 4 {
|
|
| 695 |
+ return Err(CodeSignError::macho(path, "file is too small")); |
|
| 696 |
+ } |
|
| 697 |
+ |
|
| 698 |
+ let magic = BigEndian::read_u32(&data[0..4]); |
|
| 699 |
+ if magic == FAT_MAGIC_64 {
|
|
| 700 |
+ return Err(CodeSignError::macho( |
|
| 701 |
+ path, |
|
| 702 |
+ "64-bit fat Mach-O headers are not supported yet", |
|
| 703 |
+ )); |
|
| 704 |
+ } |
|
| 705 |
+ if magic != FAT_MAGIC {
|
|
| 706 |
+ return Ok(None); |
|
| 707 |
+ } |
|
| 708 |
+ |
|
| 709 |
+ ensure_len(path, data, FAT_HEADER_SIZE, "fat header")?; |
|
| 710 |
+ let nfat_arch = BigEndian::read_u32(&data[4..8]) as usize; |
|
| 711 |
+ let table_len = checked_mul(path, nfat_arch, FAT_ARCH_SIZE, "fat architecture table")?; |
|
| 712 |
+ ensure_len( |
|
| 713 |
+ path, |
|
| 714 |
+ data, |
|
| 715 |
+ FAT_HEADER_SIZE + table_len, |
|
| 716 |
+ "fat architecture table", |
|
| 717 |
+ )?; |
|
| 718 |
+ |
|
| 719 |
+ let mut arches = Vec::with_capacity(nfat_arch); |
|
| 720 |
+ for index in 0..nfat_arch {
|
|
| 721 |
+ let offset = FAT_HEADER_SIZE + index * FAT_ARCH_SIZE; |
|
| 722 |
+ let arch_offset = BigEndian::read_u32(&data[offset + 8..offset + 12]) as usize; |
|
| 723 |
+ let size = BigEndian::read_u32(&data[offset + 12..offset + 16]) as usize; |
|
| 724 |
+ let align = BigEndian::read_u32(&data[offset + 16..offset + 20]); |
|
| 725 |
+ ensure_len(path, data, arch_offset + size, "fat architecture slice")?; |
|
| 726 |
+ arches.push(FatArch {
|
|
| 727 |
+ offset: arch_offset, |
|
| 728 |
+ size, |
|
| 729 |
+ align, |
|
| 730 |
+ }); |
|
| 731 |
+ } |
|
| 732 |
+ |
|
| 733 |
+ Ok(Some(arches)) |
|
| 734 |
+} |
|
| 735 |
+ |
|
| 736 |
+fn rebuild_fat(path: &Path, arches: &[SignedArch]) -> Result<Vec<u8>> {
|
|
| 737 |
+ let table_len = checked_mul(path, arches.len(), FAT_ARCH_SIZE, "fat architecture table")?; |
|
| 738 |
+ let mut records = Vec::with_capacity(arches.len()); |
|
| 739 |
+ let mut cursor = FAT_HEADER_SIZE + table_len; |
|
| 740 |
+ |
|
| 741 |
+ for arch in arches {
|
|
| 742 |
+ let alignment = arch_alignment(path, arch.align)?; |
|
| 743 |
+ cursor = align_to(cursor, alignment); |
|
| 744 |
+ records.push((cursor, arch)); |
|
| 745 |
+ cursor = checked_add(path, cursor, arch.data.len(), "fat Mach-O data")?; |
|
| 746 |
+ } |
|
| 747 |
+ |
|
| 748 |
+ if cursor > u32::MAX as usize {
|
|
| 749 |
+ return Err(CodeSignError::macho( |
|
| 750 |
+ path, |
|
| 751 |
+ "fat Mach-O exceeds 32-bit offsets", |
|
| 752 |
+ )); |
|
| 753 |
+ } |
|
| 754 |
+ |
|
| 755 |
+ let mut out = vec![0; FAT_HEADER_SIZE + table_len]; |
|
| 756 |
+ BigEndian::write_u32(&mut out[0..4], FAT_MAGIC); |
|
| 757 |
+ BigEndian::write_u32(&mut out[4..8], arches.len() as u32); |
|
| 758 |
+ |
|
| 759 |
+ for (index, (offset, arch)) in records.iter().enumerate() {
|
|
| 760 |
+ let record_offset = FAT_HEADER_SIZE + index * FAT_ARCH_SIZE; |
|
| 761 |
+ BigEndian::write_i32(&mut out[record_offset..record_offset + 4], arch.cputype); |
|
| 762 |
+ BigEndian::write_i32( |
|
| 763 |
+ &mut out[record_offset + 4..record_offset + 8], |
|
| 764 |
+ arch.cpusubtype, |
|
| 765 |
+ ); |
|
| 766 |
+ BigEndian::write_u32( |
|
| 767 |
+ &mut out[record_offset + 8..record_offset + 12], |
|
| 768 |
+ *offset as u32, |
|
| 769 |
+ ); |
|
| 770 |
+ BigEndian::write_u32( |
|
| 771 |
+ &mut out[record_offset + 12..record_offset + 16], |
|
| 772 |
+ arch.data.len() as u32, |
|
| 773 |
+ ); |
|
| 774 |
+ BigEndian::write_u32(&mut out[record_offset + 16..record_offset + 20], arch.align); |
|
| 775 |
+ } |
|
| 776 |
+ |
|
| 777 |
+ for (offset, arch) in records {
|
|
| 778 |
+ out.resize(offset, 0); |
|
| 779 |
+ out.extend_from_slice(&arch.data); |
|
| 780 |
+ } |
|
| 781 |
+ |
|
| 782 |
+ Ok(out) |
|
| 783 |
+} |
|
| 784 |
+ |
|
| 785 |
+fn parse_segment_32( |
|
| 786 |
+ path: &Path, |
|
| 787 |
+ data: &[u8], |
|
| 788 |
+ offset: usize, |
|
| 789 |
+ cmdsize: usize, |
|
| 790 |
+ linkedit_command_offset: &mut Option<usize>, |
|
| 791 |
+ exec_segment: &mut SegmentBounds, |
|
| 792 |
+ first_section_offset: &mut Option<usize>, |
|
| 793 |
+) -> Result<()> {
|
|
| 794 |
+ if cmdsize < 56 {
|
|
| 795 |
+ return Err(CodeSignError::macho(path, "LC_SEGMENT is too small")); |
|
| 796 |
+ } |
|
| 797 |
+ |
|
| 798 |
+ let name = segment_name(&data[offset + 8..offset + 24]); |
|
| 799 |
+ let fileoff = LittleEndian::read_u32(&data[offset + 32..offset + 36]) as u64; |
|
| 800 |
+ let filesize = LittleEndian::read_u32(&data[offset + 36..offset + 40]) as u64; |
|
| 801 |
+ let nsects = LittleEndian::read_u32(&data[offset + 48..offset + 52]) as usize; |
|
| 802 |
+ if name == "__TEXT" {
|
|
| 803 |
+ exec_segment.base = fileoff; |
|
| 804 |
+ exec_segment.limit = fileoff + filesize; |
|
| 805 |
+ } else if name == "__LINKEDIT" {
|
|
| 806 |
+ *linkedit_command_offset = Some(offset); |
|
| 807 |
+ } |
|
| 808 |
+ |
|
| 809 |
+ let sections_offset = offset + 56; |
|
| 810 |
+ let sections_size = checked_mul(path, nsects, 68, "LC_SEGMENT sections")?; |
|
| 811 |
+ if sections_offset + sections_size > offset + cmdsize {
|
|
| 812 |
+ return Err(CodeSignError::macho( |
|
| 813 |
+ path, |
|
| 814 |
+ "LC_SEGMENT sections exceed cmdsize", |
|
| 815 |
+ )); |
|
| 816 |
+ } |
|
| 817 |
+ |
|
| 818 |
+ if nsects == 0 {
|
|
| 819 |
+ if fileoff != 0 && filesize != 0 {
|
|
| 820 |
+ record_first_section_offset(first_section_offset, fileoff as usize); |
|
| 821 |
+ } |
|
| 822 |
+ } else {
|
|
| 823 |
+ for index in 0..nsects {
|
|
| 824 |
+ let section_offset = sections_offset + index * 68; |
|
| 825 |
+ let size = LittleEndian::read_u32(&data[section_offset + 36..section_offset + 40]); |
|
| 826 |
+ let file_offset = |
|
| 827 |
+ LittleEndian::read_u32(&data[section_offset + 40..section_offset + 44]) as usize; |
|
| 828 |
+ let flags = LittleEndian::read_u32(&data[section_offset + 56..section_offset + 60]); |
|
| 829 |
+ if section_has_file_contents(size as u64, flags) {
|
|
| 830 |
+ record_first_section_offset(first_section_offset, file_offset); |
|
| 831 |
+ } |
|
| 832 |
+ } |
|
| 833 |
+ } |
|
| 834 |
+ |
|
| 835 |
+ Ok(()) |
|
| 836 |
+} |
|
| 837 |
+ |
|
| 838 |
+fn parse_segment_64( |
|
| 839 |
+ path: &Path, |
|
| 840 |
+ data: &[u8], |
|
| 841 |
+ offset: usize, |
|
| 842 |
+ cmdsize: usize, |
|
| 843 |
+ linkedit_command_offset: &mut Option<usize>, |
|
| 844 |
+ exec_segment: &mut SegmentBounds, |
|
| 845 |
+ first_section_offset: &mut Option<usize>, |
|
| 846 |
+) -> Result<()> {
|
|
| 847 |
+ if cmdsize < 72 {
|
|
| 848 |
+ return Err(CodeSignError::macho(path, "LC_SEGMENT_64 is too small")); |
|
| 849 |
+ } |
|
| 850 |
+ |
|
| 851 |
+ let name = segment_name(&data[offset + 8..offset + 24]); |
|
| 852 |
+ let fileoff = LittleEndian::read_u64(&data[offset + 40..offset + 48]); |
|
| 853 |
+ let filesize = LittleEndian::read_u64(&data[offset + 48..offset + 56]); |
|
| 854 |
+ let nsects = LittleEndian::read_u32(&data[offset + 64..offset + 68]) as usize; |
|
| 855 |
+ |
|
| 856 |
+ if name == "__TEXT" {
|
|
| 857 |
+ exec_segment.base = fileoff; |
|
| 858 |
+ exec_segment.limit = fileoff + filesize; |
|
| 859 |
+ } else if name == "__LINKEDIT" {
|
|
| 860 |
+ *linkedit_command_offset = Some(offset); |
|
| 861 |
+ } |
|
| 862 |
+ |
|
| 863 |
+ let sections_offset = offset + 72; |
|
| 864 |
+ let sections_size = checked_mul(path, nsects, 80, "LC_SEGMENT_64 sections")?; |
|
| 865 |
+ if sections_offset + sections_size > offset + cmdsize {
|
|
| 866 |
+ return Err(CodeSignError::macho( |
|
| 867 |
+ path, |
|
| 868 |
+ "LC_SEGMENT_64 sections exceed cmdsize", |
|
| 869 |
+ )); |
|
| 870 |
+ } |
|
| 871 |
+ |
|
| 872 |
+ if nsects == 0 {
|
|
| 873 |
+ if fileoff != 0 && filesize != 0 {
|
|
| 874 |
+ record_first_section_offset( |
|
| 875 |
+ first_section_offset, |
|
| 876 |
+ usize::try_from(fileoff) |
|
| 877 |
+ .map_err(|_| CodeSignError::macho(path, "segment file offset exceeds usize"))?, |
|
| 878 |
+ ); |
|
| 879 |
+ } |
|
| 880 |
+ } else {
|
|
| 881 |
+ for index in 0..nsects {
|
|
| 882 |
+ let section_offset = sections_offset + index * 80; |
|
| 883 |
+ let size = LittleEndian::read_u64(&data[section_offset + 40..section_offset + 48]); |
|
| 884 |
+ let file_offset = |
|
| 885 |
+ LittleEndian::read_u32(&data[section_offset + 48..section_offset + 52]) as usize; |
|
| 886 |
+ let flags = LittleEndian::read_u32(&data[section_offset + 68..section_offset + 72]); |
|
| 887 |
+ if section_has_file_contents(size, flags) {
|
|
| 888 |
+ record_first_section_offset(first_section_offset, file_offset); |
|
| 889 |
+ } |
|
| 890 |
+ } |
|
| 891 |
+ } |
|
| 892 |
+ |
|
| 893 |
+ Ok(()) |
|
| 894 |
+} |
|
| 895 |
+ |
|
| 896 |
+fn section_has_file_contents(size: u64, flags: u32) -> bool {
|
|
| 897 |
+ let section_type = flags & SECTION_TYPE; |
|
| 898 |
+ size != 0 && section_type != S_ZEROFILL && section_type != S_THREAD_LOCAL_ZEROFILL |
|
| 899 |
+} |
|
| 900 |
+ |
|
| 901 |
+fn record_first_section_offset(first_section_offset: &mut Option<usize>, file_offset: usize) {
|
|
| 902 |
+ if file_offset == 0 {
|
|
| 903 |
+ return; |
|
| 904 |
+ } |
|
| 905 |
+ |
|
| 906 |
+ match first_section_offset {
|
|
| 907 |
+ Some(existing) if *existing <= file_offset => {}
|
|
| 908 |
+ _ => *first_section_offset = Some(file_offset), |
|
| 909 |
+ } |
|
| 910 |
+} |
|
| 911 |
+ |
|
| 912 |
+fn segment_name(bytes: &[u8]) -> &str {
|
|
| 913 |
+ let end = bytes |
|
| 914 |
+ .iter() |
|
| 915 |
+ .position(|byte| *byte == 0) |
|
| 916 |
+ .unwrap_or(bytes.len()); |
|
| 917 |
+ std::str::from_utf8(&bytes[..end]).unwrap_or("")
|
|
| 918 |
+} |
|
| 919 |
+ |
|
| 920 |
+fn ensure_len(path: &Path, data: &[u8], required: usize, what: &str) -> Result<()> {
|
|
| 921 |
+ if data.len() < required {
|
|
| 922 |
+ return Err(CodeSignError::macho( |
|
| 923 |
+ path, |
|
| 924 |
+ format!( |
|
| 925 |
+ "{what} requires {required} bytes, file only has {}",
|
|
| 926 |
+ data.len() |
|
| 927 |
+ ), |
|
| 928 |
+ )); |
|
| 929 |
+ } |
|
| 930 |
+ Ok(()) |
|
| 931 |
+} |
|
| 932 |
+ |
|
| 933 |
+fn checked_add(path: &Path, a: usize, b: usize, what: &str) -> Result<usize> {
|
|
| 934 |
+ a.checked_add(b) |
|
| 935 |
+ .ok_or_else(|| CodeSignError::macho(path, format!("{what} offset overflow")))
|
|
| 936 |
+} |
|
| 937 |
+ |
|
| 938 |
+fn checked_mul(path: &Path, a: usize, b: usize, what: &str) -> Result<usize> {
|
|
| 939 |
+ a.checked_mul(b) |
|
| 940 |
+ .ok_or_else(|| CodeSignError::macho(path, format!("{what} size overflow")))
|
|
| 941 |
+} |
|
| 942 |
+ |
|
| 943 |
+fn page_ceil(value: usize) -> usize {
|
|
| 944 |
+ align_to(value, PAGE_SIZE) |
|
| 945 |
+} |
|
| 946 |
+ |
|
| 947 |
+fn page_ceil_u64(value: u64) -> u64 {
|
|
| 948 |
+ let alignment = PAGE_SIZE as u64; |
|
| 949 |
+ value.saturating_add(alignment - 1) & !(alignment - 1) |
|
| 950 |
+} |
|
| 951 |
+ |
|
| 952 |
+fn align_to(value: usize, alignment: usize) -> usize {
|
|
| 953 |
+ debug_assert!(alignment.is_power_of_two()); |
|
| 954 |
+ (value + alignment - 1) & !(alignment - 1) |
|
| 955 |
+} |
|
| 956 |
+ |
|
| 957 |
+fn arch_alignment(path: &Path, align: u32) -> Result<usize> {
|
|
| 958 |
+ 1usize |
|
| 959 |
+ .checked_shl(align) |
|
| 960 |
+ .filter(|alignment| *alignment > 0) |
|
| 961 |
+ .ok_or_else(|| CodeSignError::macho(path, format!("invalid fat arch alignment {align}")))
|
|
| 962 |
+} |
|
| 963 |
+ |
|
| 964 |
+#[cfg(test)] |
|
| 965 |
+mod tests {
|
|
| 966 |
+ use super::*; |
|
| 967 |
+ use crate::signature::{CSMAGIC_EMBEDDED_SIGNATURE, CmsSigningRequest};
|
|
| 968 |
+ |
|
| 969 |
+ const CPU_TYPE_ARM64_TEST: i32 = 0x0100_000c; |
|
| 970 |
+ |
|
| 971 |
+ struct TestCmsSigner; |
|
| 972 |
+ |
|
| 973 |
+ impl CmsSigner for TestCmsSigner {
|
|
| 974 |
+ fn sign(&self, _: CmsSigningRequest<'_>) -> Result<Vec<u8>> {
|
|
| 975 |
+ Ok(vec![0x30, 0x00]) |
|
| 976 |
+ } |
|
| 977 |
+ } |
|
| 978 |
+ |
|
| 979 |
+ #[test] |
|
| 980 |
+ fn signs_thin_macho_by_growing_existing_code_signature() {
|
|
| 981 |
+ let macho = minimal_macho_with_empty_signature(); |
|
| 982 |
+ let entitlements = Dictionary::new(); |
|
| 983 |
+ let config = MachOSigningConfig::new("com.example.test", "TEAMID", &entitlements, None);
|
|
| 984 |
+ |
|
| 985 |
+ let signed = sign_macho_data(Path::new("TestBinary"), &macho, &config).unwrap();
|
|
| 986 |
+ |
|
| 987 |
+ let code_sig_cmd = 32 + 72 + 72; |
|
| 988 |
+ let dataoff = LittleEndian::read_u32(&signed[code_sig_cmd + 8..code_sig_cmd + 12]) as usize; |
|
| 989 |
+ let datasize = |
|
| 990 |
+ LittleEndian::read_u32(&signed[code_sig_cmd + 12..code_sig_cmd + 16]) as usize; |
|
| 991 |
+ |
|
| 992 |
+ assert!(datasize > 0); |
|
| 993 |
+ assert_eq!(signed.len(), dataoff + datasize); |
|
| 994 |
+ assert_eq!( |
|
| 995 |
+ BigEndian::read_u32(&signed[dataoff..dataoff + 4]), |
|
| 996 |
+ CSMAGIC_EMBEDDED_SIGNATURE |
|
| 997 |
+ ); |
|
| 998 |
+ } |
|
| 999 |
+ |
|
| 1000 |
+ #[test] |
|
| 1001 |
+ fn cms_reservation_is_zero_padding_after_superblob() {
|
|
| 1002 |
+ let macho = minimal_macho_with_empty_signature(); |
|
| 1003 |
+ let entitlements = Dictionary::new(); |
|
| 1004 |
+ let signer = TestCmsSigner; |
|
| 1005 |
+ let mut config = |
|
| 1006 |
+ MachOSigningConfig::new("com.example.test", "TEAMID", &entitlements, Some(&signer));
|
|
| 1007 |
+ config.cms_blob_reservation = 1024; |
|
| 1008 |
+ |
|
| 1009 |
+ let signed = sign_macho_data(Path::new("TestBinary"), &macho, &config).unwrap();
|
|
| 1010 |
+ let code_sig_cmd = 32 + 72 + 72; |
|
| 1011 |
+ let dataoff = LittleEndian::read_u32(&signed[code_sig_cmd + 8..code_sig_cmd + 12]) as usize; |
|
| 1012 |
+ let datasize = |
|
| 1013 |
+ LittleEndian::read_u32(&signed[code_sig_cmd + 12..code_sig_cmd + 16]) as usize; |
|
| 1014 |
+ let superblob_len = BigEndian::read_u32(&signed[dataoff + 4..dataoff + 8]) as usize; |
|
| 1015 |
+ |
|
| 1016 |
+ assert!(superblob_len < datasize); |
|
| 1017 |
+ assert!( |
|
| 1018 |
+ signed[dataoff + superblob_len..dataoff + datasize] |
|
| 1019 |
+ .iter() |
|
| 1020 |
+ .all(|byte| *byte == 0) |
|
| 1021 |
+ ); |
|
| 1022 |
+ } |
|
| 1023 |
+ |
|
| 1024 |
+ #[test] |
|
| 1025 |
+ fn allocates_code_signature_command_when_headerpad_exists() {
|
|
| 1026 |
+ let macho = minimal_macho_without_signature(128); |
|
| 1027 |
+ let entitlements = Dictionary::new(); |
|
| 1028 |
+ let config = MachOSigningConfig::new("com.example.test", "TEAMID", &entitlements, None);
|
|
| 1029 |
+ |
|
| 1030 |
+ let signed = sign_macho_data(Path::new("HeaderPaddedBinary"), &macho, &config).unwrap();
|
|
| 1031 |
+ |
|
| 1032 |
+ let code_sig_cmd = 32 + 152 + 72; |
|
| 1033 |
+ assert_eq!( |
|
| 1034 |
+ LittleEndian::read_u32(&signed[code_sig_cmd..code_sig_cmd + 4]), |
|
| 1035 |
+ LC_CODE_SIGNATURE |
|
| 1036 |
+ ); |
|
| 1037 |
+ |
|
| 1038 |
+ let dataoff = LittleEndian::read_u32(&signed[code_sig_cmd + 8..code_sig_cmd + 12]) as usize; |
|
| 1039 |
+ let datasize = |
|
| 1040 |
+ LittleEndian::read_u32(&signed[code_sig_cmd + 12..code_sig_cmd + 16]) as usize; |
|
| 1041 |
+ let linkedit_cmd = 32 + 152; |
|
| 1042 |
+ let linkedit_filesize = |
|
| 1043 |
+ LittleEndian::read_u64(&signed[linkedit_cmd + 48..linkedit_cmd + 56]); |
|
| 1044 |
+ |
|
| 1045 |
+ assert_eq!(dataoff % CODE_SIGNATURE_ALIGNMENT, 0); |
|
| 1046 |
+ assert!(datasize > 0); |
|
| 1047 |
+ assert_eq!(signed.len(), dataoff + datasize); |
|
| 1048 |
+ assert_eq!(linkedit_filesize as usize, signed.len() - 512); |
|
| 1049 |
+ assert_eq!( |
|
| 1050 |
+ BigEndian::read_u32(&signed[dataoff..dataoff + 4]), |
|
| 1051 |
+ CSMAGIC_EMBEDDED_SIGNATURE |
|
| 1052 |
+ ); |
|
| 1053 |
+ } |
|
| 1054 |
+ |
|
| 1055 |
+ #[test] |
|
| 1056 |
+ fn refuses_to_add_code_signature_command_without_headerpad() {
|
|
| 1057 |
+ let macho = minimal_macho_without_signature(0); |
|
| 1058 |
+ let entitlements = Dictionary::new(); |
|
| 1059 |
+ let config = MachOSigningConfig::new("com.example.test", "TEAMID", &entitlements, None);
|
|
| 1060 |
+ |
|
| 1061 |
+ let err = sign_macho_data(Path::new("TightBinary"), &macho, &config).unwrap_err();
|
|
| 1062 |
+ |
|
| 1063 |
+ assert!(matches!( |
|
| 1064 |
+ err, |
|
| 1065 |
+ CodeSignError::NeedsCodeSignatureAllocation { .. }
|
|
| 1066 |
+ )); |
|
| 1067 |
+ } |
|
| 1068 |
+ |
|
| 1069 |
+ fn minimal_macho_with_empty_signature() -> Vec<u8> {
|
|
| 1070 |
+ let sizeofcmds = 72 + 72 + 16; |
|
| 1071 |
+ let dataoff = 512usize; |
|
| 1072 |
+ let mut data = vec![0; dataoff]; |
|
| 1073 |
+ |
|
| 1074 |
+ LittleEndian::write_u32(&mut data[0..4], MH_MAGIC_64); |
|
| 1075 |
+ LittleEndian::write_i32(&mut data[4..8], CPU_TYPE_ARM64_TEST); |
|
| 1076 |
+ LittleEndian::write_i32(&mut data[8..12], 0); |
|
| 1077 |
+ LittleEndian::write_u32(&mut data[12..16], MH_EXECUTE); |
|
| 1078 |
+ LittleEndian::write_u32(&mut data[16..20], 3); |
|
| 1079 |
+ LittleEndian::write_u32(&mut data[20..24], sizeofcmds as u32); |
|
| 1080 |
+ |
|
| 1081 |
+ write_segment_64(&mut data, 32, "__TEXT", 0, dataoff as u64); |
|
| 1082 |
+ write_segment_64(&mut data, 32 + 72, "__LINKEDIT", dataoff as u64, 0); |
|
| 1083 |
+ |
|
| 1084 |
+ let code_sig_cmd = 32 + 72 + 72; |
|
| 1085 |
+ LittleEndian::write_u32(&mut data[code_sig_cmd..code_sig_cmd + 4], LC_CODE_SIGNATURE); |
|
| 1086 |
+ LittleEndian::write_u32( |
|
| 1087 |
+ &mut data[code_sig_cmd + 4..code_sig_cmd + 8], |
|
| 1088 |
+ LINKEDIT_DATA_COMMAND_SIZE as u32, |
|
| 1089 |
+ ); |
|
| 1090 |
+ LittleEndian::write_u32( |
|
| 1091 |
+ &mut data[code_sig_cmd + 8..code_sig_cmd + 12], |
|
| 1092 |
+ dataoff as u32, |
|
| 1093 |
+ ); |
|
| 1094 |
+ LittleEndian::write_u32(&mut data[code_sig_cmd + 12..code_sig_cmd + 16], 0); |
|
| 1095 |
+ |
|
| 1096 |
+ data |
|
| 1097 |
+ } |
|
| 1098 |
+ |
|
| 1099 |
+ fn minimal_macho_without_signature(headerpad: usize) -> Vec<u8> {
|
|
| 1100 |
+ let sizeofcmds = 152 + 72; |
|
| 1101 |
+ let content_offset = 32 + sizeofcmds + headerpad; |
|
| 1102 |
+ let linkedit_offset = 512usize; |
|
| 1103 |
+ let mut data = vec![0; linkedit_offset + 33]; |
|
| 1104 |
+ |
|
| 1105 |
+ LittleEndian::write_u32(&mut data[0..4], MH_MAGIC_64); |
|
| 1106 |
+ LittleEndian::write_i32(&mut data[4..8], CPU_TYPE_ARM64_TEST); |
|
| 1107 |
+ LittleEndian::write_i32(&mut data[8..12], 0); |
|
| 1108 |
+ LittleEndian::write_u32(&mut data[12..16], MH_EXECUTE); |
|
| 1109 |
+ LittleEndian::write_u32(&mut data[16..20], 2); |
|
| 1110 |
+ LittleEndian::write_u32(&mut data[20..24], sizeofcmds as u32); |
|
| 1111 |
+ |
|
| 1112 |
+ write_segment_64_with_section( |
|
| 1113 |
+ &mut data, |
|
| 1114 |
+ 32, |
|
| 1115 |
+ "__TEXT", |
|
| 1116 |
+ 0, |
|
| 1117 |
+ linkedit_offset as u64, |
|
| 1118 |
+ content_offset as u32, |
|
| 1119 |
+ 1, |
|
| 1120 |
+ ); |
|
| 1121 |
+ write_segment_64( |
|
| 1122 |
+ &mut data, |
|
| 1123 |
+ 32 + 152, |
|
| 1124 |
+ "__LINKEDIT", |
|
| 1125 |
+ linkedit_offset as u64, |
|
| 1126 |
+ 33, |
|
| 1127 |
+ ); |
|
| 1128 |
+ |
|
| 1129 |
+ data[content_offset] = 0xaa; |
|
| 1130 |
+ data[linkedit_offset] = 0xbb; |
|
| 1131 |
+ data |
|
| 1132 |
+ } |
|
| 1133 |
+ |
|
| 1134 |
+ fn write_segment_64(data: &mut [u8], offset: usize, name: &str, fileoff: u64, filesize: u64) {
|
|
| 1135 |
+ LittleEndian::write_u32(&mut data[offset..offset + 4], LC_SEGMENT_64); |
|
| 1136 |
+ LittleEndian::write_u32(&mut data[offset + 4..offset + 8], 72); |
|
| 1137 |
+ data[offset + 8..offset + 8 + name.len()].copy_from_slice(name.as_bytes()); |
|
| 1138 |
+ LittleEndian::write_u64(&mut data[offset + 24..offset + 32], 0); |
|
| 1139 |
+ LittleEndian::write_u64( |
|
| 1140 |
+ &mut data[offset + 32..offset + 40], |
|
| 1141 |
+ page_ceil(filesize as usize) as u64, |
|
| 1142 |
+ ); |
|
| 1143 |
+ LittleEndian::write_u64(&mut data[offset + 40..offset + 48], fileoff); |
|
| 1144 |
+ LittleEndian::write_u64(&mut data[offset + 48..offset + 56], filesize); |
|
| 1145 |
+ LittleEndian::write_u32(&mut data[offset + 64..offset + 68], 0); |
|
| 1146 |
+ } |
|
| 1147 |
+ |
|
| 1148 |
+ fn write_segment_64_with_section( |
|
| 1149 |
+ data: &mut [u8], |
|
| 1150 |
+ offset: usize, |
|
| 1151 |
+ name: &str, |
|
| 1152 |
+ fileoff: u64, |
|
| 1153 |
+ filesize: u64, |
|
| 1154 |
+ section_offset: u32, |
|
| 1155 |
+ section_size: u64, |
|
| 1156 |
+ ) {
|
|
| 1157 |
+ LittleEndian::write_u32(&mut data[offset..offset + 4], LC_SEGMENT_64); |
|
| 1158 |
+ LittleEndian::write_u32(&mut data[offset + 4..offset + 8], 152); |
|
| 1159 |
+ data[offset + 8..offset + 8 + name.len()].copy_from_slice(name.as_bytes()); |
|
| 1160 |
+ LittleEndian::write_u64(&mut data[offset + 24..offset + 32], 0); |
|
| 1161 |
+ LittleEndian::write_u64( |
|
| 1162 |
+ &mut data[offset + 32..offset + 40], |
|
| 1163 |
+ page_ceil(filesize as usize) as u64, |
|
| 1164 |
+ ); |
|
| 1165 |
+ LittleEndian::write_u64(&mut data[offset + 40..offset + 48], fileoff); |
|
| 1166 |
+ LittleEndian::write_u64(&mut data[offset + 48..offset + 56], filesize); |
|
| 1167 |
+ LittleEndian::write_u32(&mut data[offset + 64..offset + 68], 1); |
|
| 1168 |
+ |
|
| 1169 |
+ let section = offset + 72; |
|
| 1170 |
+ LittleEndian::write_u64(&mut data[section + 32..section + 40], 0); |
|
| 1171 |
+ LittleEndian::write_u64(&mut data[section + 40..section + 48], section_size); |
|
| 1172 |
+ LittleEndian::write_u32(&mut data[section + 48..section + 52], section_offset); |
|
| 1173 |
+ LittleEndian::write_u32(&mut data[section + 68..section + 72], 0); |
|
| 1174 |
+ } |
|
| 1175 |
+} |
| ... | ... |
@@ -1,126 +1,216 @@ |
| 1 |
-use futures::future::join_all; |
|
| 2 |
-use futures::{FutureExt, future::BoxFuture};
|
|
| 3 |
-use log::warn; |
|
| 4 |
-use plist::{Dictionary, Value};
|
|
| 5 |
-use sha1::Digest as _; |
|
| 6 |
-use sha2::Digest as _; |
|
| 1 |
+use apple_codesign::{
|
|
| 2 |
+ BundleSigningSettings, CodeSignError, ProvisioningProfile, Result, RustCryptoCmsSigner, |
|
| 3 |
+ sign_bundle, |
|
| 4 |
+}; |
|
| 5 |
+use std::collections::BTreeMap; |
|
| 6 |
+use std::ffi::OsString; |
|
| 7 | 7 |
use std::fs; |
| 8 | 8 |
use std::path::{Path, PathBuf};
|
| 9 |
-use tokio::task::{block_in_place, spawn_blocking};
|
|
| 10 |
- |
|
| 11 |
-struct Bundle {
|
|
| 12 |
- path: PathBuf, |
|
| 13 |
-} |
|
| 14 |
- |
|
| 15 |
-impl Bundle {
|
|
| 16 |
- async fn code_resources(&self) -> Dictionary {
|
|
| 17 |
- struct CodeResourceFile {
|
|
| 18 |
- path: PathBuf, |
|
| 19 |
- sha1: Vec<u8>, |
|
| 20 |
- sha256: Vec<u8>, |
|
| 21 |
- } |
|
| 22 |
- |
|
| 23 |
- async fn hash_file(relative_path: PathBuf, path: PathBuf) -> CodeResourceFile {
|
|
| 24 |
- let file_data = fs::read(&path).unwrap(); // FIXME unwrap |
|
| 25 |
- let file_data2 = file_data.clone(); |
|
| 26 |
- let sha1 = spawn_blocking(move || sha1::Sha1::digest(&file_data)[..].into()); |
|
| 27 |
- let sha256 = spawn_blocking(move || sha2::Sha256::digest(&file_data2)[..].into()); |
|
| 28 |
- CodeResourceFile {
|
|
| 29 |
- path: relative_path, |
|
| 30 |
- sha1: sha1.await.unwrap(), |
|
| 31 |
- sha256: sha256.await.unwrap(), |
|
| 32 |
- } |
|
| 33 |
- } |
|
| 34 |
- |
|
| 35 |
- fn resources_for_directory( |
|
| 36 |
- root: &Path, |
|
| 37 |
- directory: PathBuf, |
|
| 38 |
- ) -> Vec<BoxFuture<'static, CodeResourceFile>> {
|
|
| 39 |
- let mut output = Vec::new(); |
|
| 40 |
- let mut subfolders = Vec::new(); |
|
| 41 |
- if let Ok(read_dir) = fs::read_dir(directory) {
|
|
| 42 |
- for entry in read_dir {
|
|
| 43 |
- if let Ok(entry) = entry {
|
|
| 44 |
- let metadata = entry.metadata().expect("metadata call failed");
|
|
| 45 |
- let path = entry.path(); |
|
| 46 |
- if metadata.is_file() {
|
|
| 47 |
- if let Ok(relative_path) = entry.path().strip_prefix(root) {
|
|
| 48 |
- // TODO check rules |
|
| 49 |
- output.push(hash_file(relative_path.to_path_buf(), path).boxed()); |
|
| 50 |
- } else {
|
|
| 51 |
- warn!("Skipping {:?} as its prefix is not correct", entry.path());
|
|
| 52 |
- } |
|
| 53 |
- } else if metadata.is_dir() {
|
|
| 54 |
- subfolders.push(resources_for_directory(root, path)); |
|
| 55 |
- } |
|
| 9 |
+ |
|
| 10 |
+fn main() -> Result<()> {
|
|
| 11 |
+ let Some(args) = CliArgs::parse()? else {
|
|
| 12 |
+ print_usage(); |
|
| 13 |
+ return Ok(()); |
|
| 14 |
+ }; |
|
| 15 |
+ |
|
| 16 |
+ let mobileprovision = read_file(&args.mobileprovision)?; |
|
| 17 |
+ let profile = ProvisioningProfile::parse(&mobileprovision)?; |
|
| 18 |
+ let private_key = read_der_or_pem(&args.private_key, &["PRIVATE KEY", "RSA PRIVATE KEY"])?; |
|
| 19 |
+ let certificate = read_der_or_pem(&args.certificate, &["CERTIFICATE"])?; |
|
| 20 |
+ let mut certificate_chain = |
|
| 21 |
+ Vec::with_capacity(args.certificate_chain.len() + profile.certificate_chain_der().len()); |
|
| 22 |
+ |
|
| 23 |
+ for certificate in &args.certificate_chain {
|
|
| 24 |
+ certificate_chain.push(read_der_or_pem(certificate, &["CERTIFICATE"])?); |
|
| 25 |
+ } |
|
| 26 |
+ certificate_chain.extend(profile.certificate_chain_der().iter().cloned()); |
|
| 27 |
+ |
|
| 28 |
+ let signer = RustCryptoCmsSigner::from_der( |
|
| 29 |
+ &private_key, |
|
| 30 |
+ &certificate, |
|
| 31 |
+ certificate_chain.iter().map(Vec::as_slice), |
|
| 32 |
+ )?; |
|
| 33 |
+ |
|
| 34 |
+ let team_id = args |
|
| 35 |
+ .team_id |
|
| 36 |
+ .as_deref() |
|
| 37 |
+ .unwrap_or_else(|| profile.team_id()) |
|
| 38 |
+ .to_string(); |
|
| 39 |
+ let mut settings = |
|
| 40 |
+ BundleSigningSettings::new(team_id, profile.entitlements().clone(), Some(&signer)); |
|
| 41 |
+ settings.embedded_mobileprovision = Some(&mobileprovision); |
|
| 42 |
+ let nested_profiles = args |
|
| 43 |
+ .profiles_by_bundle_id |
|
| 44 |
+ .iter() |
|
| 45 |
+ .map(|(bundle_id, path)| {
|
|
| 46 |
+ let data = read_file(path)?; |
|
| 47 |
+ let profile = ProvisioningProfile::parse(&data)?; |
|
| 48 |
+ let entitlements = profile.entitlements().clone(); |
|
| 49 |
+ Ok((bundle_id.clone(), data, entitlements)) |
|
| 50 |
+ }) |
|
| 51 |
+ .collect::<Result<Vec<_>>>()?; |
|
| 52 |
+ settings.embedded_mobileprovisions_by_bundle_id = nested_profiles |
|
| 53 |
+ .iter() |
|
| 54 |
+ .map(|(bundle_id, data, _)| (bundle_id.clone(), data.as_slice())) |
|
| 55 |
+ .collect::<BTreeMap<_, _>>(); |
|
| 56 |
+ settings.entitlements_by_bundle_id = nested_profiles |
|
| 57 |
+ .iter() |
|
| 58 |
+ .map(|(bundle_id, _, entitlements)| (bundle_id.clone(), entitlements.clone())) |
|
| 59 |
+ .collect(); |
|
| 60 |
+ if let Some(reservation) = args.cms_blob_reservation {
|
|
| 61 |
+ settings.cms_blob_reservation = reservation; |
|
| 62 |
+ } |
|
| 63 |
+ |
|
| 64 |
+ sign_bundle(args.bundle, &settings) |
|
| 65 |
+} |
|
| 66 |
+ |
|
| 67 |
+#[derive(Debug)] |
|
| 68 |
+struct CliArgs {
|
|
| 69 |
+ bundle: PathBuf, |
|
| 70 |
+ certificate: PathBuf, |
|
| 71 |
+ private_key: PathBuf, |
|
| 72 |
+ mobileprovision: PathBuf, |
|
| 73 |
+ certificate_chain: Vec<PathBuf>, |
|
| 74 |
+ profiles_by_bundle_id: Vec<(String, PathBuf)>, |
|
| 75 |
+ team_id: Option<String>, |
|
| 76 |
+ cms_blob_reservation: Option<usize>, |
|
| 77 |
+} |
|
| 78 |
+ |
|
| 79 |
+impl CliArgs {
|
|
| 80 |
+ fn parse() -> Result<Option<Self>> {
|
|
| 81 |
+ let raw_args: Vec<OsString> = std::env::args_os().skip(1).collect(); |
|
| 82 |
+ if raw_args.is_empty() || raw_args.iter().any(|arg| arg == "-h" || arg == "--help") {
|
|
| 83 |
+ return Ok(None); |
|
| 84 |
+ } |
|
| 85 |
+ |
|
| 86 |
+ let mut bundle = None; |
|
| 87 |
+ let mut certificate = None; |
|
| 88 |
+ let mut private_key = None; |
|
| 89 |
+ let mut mobileprovision = None; |
|
| 90 |
+ let mut certificate_chain = Vec::new(); |
|
| 91 |
+ let mut profiles_by_bundle_id = Vec::new(); |
|
| 92 |
+ let mut team_id = None; |
|
| 93 |
+ let mut cms_blob_reservation = None; |
|
| 94 |
+ |
|
| 95 |
+ let mut args = raw_args.into_iter(); |
|
| 96 |
+ while let Some(arg) = args.next() {
|
|
| 97 |
+ match arg.to_string_lossy().as_ref() {
|
|
| 98 |
+ "--bundle" | "-b" => set_path_arg(&mut bundle, "--bundle", args.next())?, |
|
| 99 |
+ "--certificate" | "--cert" | "-c" => {
|
|
| 100 |
+ set_path_arg(&mut certificate, "--certificate", args.next())? |
|
| 101 |
+ } |
|
| 102 |
+ "--private-key" | "--key" | "-k" => {
|
|
| 103 |
+ set_path_arg(&mut private_key, "--private-key", args.next())? |
|
| 104 |
+ } |
|
| 105 |
+ "--mobileprovision" | "--profile" | "-m" => {
|
|
| 106 |
+ set_path_arg(&mut mobileprovision, "--mobileprovision", args.next())? |
|
| 107 |
+ } |
|
| 108 |
+ "--chain" => certificate_chain.push(next_path_arg("--chain", args.next())?),
|
|
| 109 |
+ "--profile-for" => profiles_by_bundle_id.push(next_profile_arg(args.next())?), |
|
| 110 |
+ "--team-id" => {
|
|
| 111 |
+ team_id = Some(next_string_arg("--team-id", args.next())?);
|
|
| 112 |
+ } |
|
| 113 |
+ "--cms-reservation" => {
|
|
| 114 |
+ let value = next_string_arg("--cms-reservation", args.next())?;
|
|
| 115 |
+ cms_blob_reservation = Some(value.parse().map_err(|_| {
|
|
| 116 |
+ CodeSignError::Argument(format!( |
|
| 117 |
+ "--cms-reservation must be a byte count, got {value}"
|
|
| 118 |
+ )) |
|
| 119 |
+ })?); |
|
| 120 |
+ } |
|
| 121 |
+ value if value.starts_with('-') => {
|
|
| 122 |
+ return Err(CodeSignError::Argument(format!("unknown argument {value}")));
|
|
| 123 |
+ } |
|
| 124 |
+ _ if bundle.is_none() => {
|
|
| 125 |
+ bundle = Some(PathBuf::from(arg)); |
|
| 126 |
+ } |
|
| 127 |
+ value => {
|
|
| 128 |
+ return Err(CodeSignError::Argument(format!( |
|
| 129 |
+ "unexpected positional argument {value}"
|
|
| 130 |
+ ))); |
|
| 131 |
+ } |
|
| 132 |
+ } |
|
| 133 |
+ } |
|
| 134 |
+ |
|
| 135 |
+ Ok(Some(Self {
|
|
| 136 |
+ bundle: required_path(bundle, "--bundle")?, |
|
| 137 |
+ certificate: required_path(certificate, "--certificate")?, |
|
| 138 |
+ private_key: required_path(private_key, "--private-key")?, |
|
| 139 |
+ mobileprovision: required_path(mobileprovision, "--mobileprovision")?, |
|
| 140 |
+ certificate_chain, |
|
| 141 |
+ profiles_by_bundle_id, |
|
| 142 |
+ team_id, |
|
| 143 |
+ cms_blob_reservation, |
|
| 144 |
+ })) |
|
| 145 |
+ } |
|
| 56 | 146 |
} |
| 147 |
+ |
|
| 148 |
+fn next_profile_arg(value: Option<OsString>) -> Result<(String, PathBuf)> {
|
|
| 149 |
+ let value = next_string_arg("--profile-for", value)?;
|
|
| 150 |
+ let (bundle_id, path) = value.split_once('=').ok_or_else(|| {
|
|
| 151 |
+ CodeSignError::Argument("--profile-for requires BUNDLE_IDENTIFIER=PROFILE_PATH".to_string())
|
|
| 152 |
+ })?; |
|
| 153 |
+ if bundle_id.trim().is_empty() || path.trim().is_empty() {
|
|
| 154 |
+ return Err(CodeSignError::Argument( |
|
| 155 |
+ "--profile-for requires non-empty bundle identifier and path".to_string(), |
|
| 156 |
+ )); |
|
| 57 | 157 |
} |
| 158 |
+ Ok((bundle_id.to_string(), PathBuf::from(path))) |
|
| 58 | 159 |
} |
| 59 | 160 |
|
| 60 |
- for subfolder in subfolders {
|
|
| 61 |
- let data = subfolder; |
|
| 62 |
- output.extend(data); |
|
| 161 |
+fn set_path_arg(slot: &mut Option<PathBuf>, name: &str, value: Option<OsString>) -> Result<()> {
|
|
| 162 |
+ if slot.is_some() {
|
|
| 163 |
+ return Err(CodeSignError::Argument(format!( |
|
| 164 |
+ "{name} was provided twice"
|
|
| 165 |
+ ))); |
|
| 63 | 166 |
} |
| 64 |
- output |
|
| 167 |
+ *slot = Some(next_path_arg(name, value)?); |
|
| 168 |
+ Ok(()) |
|
| 65 | 169 |
} |
| 66 | 170 |
|
| 67 |
- let path_buf = self.path.to_path_buf(); |
|
| 68 |
- let resources = resources_for_directory(&self.path, path_buf); |
|
| 69 |
- |
|
| 70 |
- let rules = Dictionary::new(); |
|
| 71 |
- let rules2 = Dictionary::new(); |
|
| 72 |
- |
|
| 73 |
- let (files, files2) = {
|
|
| 74 |
- let mut files = Dictionary::new(); |
|
| 75 |
- let mut files2 = Dictionary::new(); |
|
| 76 |
- |
|
| 77 |
- for res in resources {
|
|
| 78 |
- let CodeResourceFile { path, sha1, sha256 } = res.await;
|
|
| 79 |
- |
|
| 80 |
- let path = path.to_string_lossy().into_owned(); |
|
| 81 |
- |
|
| 82 |
- let sha1: Vec<u8> = sha1.into(); |
|
| 83 |
- let sha256: Vec<u8> = sha256.into(); |
|
| 84 |
- |
|
| 85 |
- files.insert(path.clone(), Value::Data(sha1.clone())); |
|
| 86 |
- |
|
| 87 |
- let mut file2 = Dictionary::new(); |
|
| 88 |
- file2.insert("hash".to_string(), Value::Data(sha1));
|
|
| 89 |
- file2.insert("hash2".to_string(), Value::Data(sha256));
|
|
| 90 |
- |
|
| 91 |
- files2.insert(path, Value::Dictionary(file2)); |
|
| 171 |
+fn next_path_arg(name: &str, value: Option<OsString>) -> Result<PathBuf> {
|
|
| 172 |
+ value |
|
| 173 |
+ .map(PathBuf::from) |
|
| 174 |
+ .ok_or_else(|| CodeSignError::Argument(format!("{name} requires a value")))
|
|
| 92 | 175 |
} |
| 93 | 176 |
|
| 94 |
- (files, files2) |
|
| 95 |
- }; |
|
| 96 |
- |
|
| 97 |
- let code_resources = {
|
|
| 98 |
- let mut code_resources = Dictionary::new(); |
|
| 99 |
- code_resources.insert("files".to_string(), Value::Dictionary(files));
|
|
| 100 |
- code_resources.insert("files2".to_string(), Value::Dictionary(files2));
|
|
| 101 |
- code_resources.insert("rules".to_string(), Value::Dictionary(rules));
|
|
| 102 |
- code_resources.insert("rules2".to_string(), Value::Dictionary(rules2));
|
|
| 177 |
+fn next_string_arg(name: &str, value: Option<OsString>) -> Result<String> {
|
|
| 178 |
+ let value = value.ok_or_else(|| CodeSignError::Argument(format!("{name} requires a value")))?;
|
|
| 179 |
+ value |
|
| 180 |
+ .into_string() |
|
| 181 |
+ .map_err(|_| CodeSignError::Argument(format!("{name} must be valid UTF-8")))
|
|
| 182 |
+} |
|
| 103 | 183 |
|
| 104 |
- code_resources |
|
| 105 |
- }; |
|
| 184 |
+fn required_path(value: Option<PathBuf>, name: &str) -> Result<PathBuf> {
|
|
| 185 |
+ value.ok_or_else(|| CodeSignError::Argument(format!("missing required {name}")))
|
|
| 186 |
+} |
|
| 106 | 187 |
|
| 107 |
- code_resources |
|
| 188 |
+fn read_file(path: &Path) -> Result<Vec<u8>> {
|
|
| 189 |
+ fs::read(path).map_err(|source| CodeSignError::Io {
|
|
| 190 |
+ path: path.to_path_buf(), |
|
| 191 |
+ source, |
|
| 192 |
+ }) |
|
| 108 | 193 |
} |
| 109 | 194 |
|
| 110 |
- async fn sign(&self) {
|
|
| 111 |
- let code_resources = self.code_resources().await; |
|
| 195 |
+fn read_der_or_pem(path: &Path, allowed_labels: &[&str]) -> Result<Vec<u8>> {
|
|
| 196 |
+ let data = read_file(path)?; |
|
| 197 |
+ if !data.starts_with(b"-----BEGIN ") {
|
|
| 198 |
+ return Ok(data); |
|
| 199 |
+ } |
|
| 112 | 200 |
|
| 113 |
- let mut data = Vec::<u8>::new(); |
|
| 114 |
- plist::to_writer_xml(&mut data, &code_resources).expect("TODO: panic message");
|
|
| 115 |
- println!("{}", String::from_utf8(data).unwrap());
|
|
| 201 |
+ let (label, der) = pem_rfc7468::decode_vec(&data) |
|
| 202 |
+ .map_err(|err| CodeSignError::Argument(format!("failed to decode PEM {path:?}: {err}")))?;
|
|
| 203 |
+ if allowed_labels.contains(&label) {
|
|
| 204 |
+ Ok(der) |
|
| 205 |
+ } else {
|
|
| 206 |
+ Err(CodeSignError::Argument(format!( |
|
| 207 |
+ "unsupported PEM label {label:?} in {path:?}; expected one of {allowed_labels:?}"
|
|
| 208 |
+ ))) |
|
| 116 | 209 |
} |
| 117 | 210 |
} |
| 118 | 211 |
|
| 119 |
-#[tokio::main] |
|
| 120 |
-async fn main() {
|
|
| 121 |
- Bundle {
|
|
| 122 |
- path: "/home/dadoum/Téléchargements/YouTube.app/".into(), |
|
| 123 |
- } |
|
| 124 |
- .sign() |
|
| 125 |
- .await; |
|
| 212 |
+fn print_usage() {
|
|
| 213 |
+ eprintln!( |
|
| 214 |
+ "usage: apple-codesign --bundle Bundle.app --certificate signer.cer --private-key signer.key --mobileprovision profile.mobileprovision [--profile-for BUNDLE_IDENTIFIER=PROFILE_PATH ...] [--chain wwdr.cer ...] [--team-id TEAMID] [--cms-reservation BYTES]" |
|
| 215 |
+ ); |
|
| 126 | 216 |
} |
| ... | ... |
@@ -0,0 +1,128 @@ |
| 1 |
+use crate::error::{CodeSignError, Result};
|
|
| 2 |
+use cms::cert::CertificateChoices; |
|
| 3 |
+use cms::content_info::ContentInfo; |
|
| 4 |
+use cms::signed_data::SignedData; |
|
| 5 |
+use der::{Decode, Encode};
|
|
| 6 |
+use plist::{Dictionary, Value};
|
|
| 7 |
+ |
|
| 8 |
+#[derive(Clone, Debug)] |
|
| 9 |
+pub struct ProvisioningProfile {
|
|
| 10 |
+ plist: Dictionary, |
|
| 11 |
+ team_id: String, |
|
| 12 |
+ entitlements: Dictionary, |
|
| 13 |
+ certificate_chain: Vec<Vec<u8>>, |
|
| 14 |
+} |
|
| 15 |
+ |
|
| 16 |
+impl ProvisioningProfile {
|
|
| 17 |
+ pub fn parse(data: &[u8]) -> Result<Self> {
|
|
| 18 |
+ let signed_data = decode_signed_data(data)?; |
|
| 19 |
+ let profile_data = signed_data |
|
| 20 |
+ .encap_content_info |
|
| 21 |
+ .econtent |
|
| 22 |
+ .as_ref() |
|
| 23 |
+ .ok_or_else(|| {
|
|
| 24 |
+ CodeSignError::provisioning_profile("CMS SignedData has no embedded plist")
|
|
| 25 |
+ })?; |
|
| 26 |
+ let plist = match Value::from_reader_xml(profile_data.value())? {
|
|
| 27 |
+ Value::Dictionary(plist) => plist, |
|
| 28 |
+ _ => {
|
|
| 29 |
+ return Err(CodeSignError::provisioning_profile( |
|
| 30 |
+ "embedded plist is not a dictionary", |
|
| 31 |
+ )); |
|
| 32 |
+ } |
|
| 33 |
+ }; |
|
| 34 |
+ |
|
| 35 |
+ let team_id = team_identifier(&plist)?; |
|
| 36 |
+ let entitlements = match plist.get("Entitlements") {
|
|
| 37 |
+ Some(Value::Dictionary(entitlements)) => entitlements.clone(), |
|
| 38 |
+ _ => {
|
|
| 39 |
+ return Err(CodeSignError::provisioning_profile( |
|
| 40 |
+ "profile is missing Entitlements dictionary", |
|
| 41 |
+ )); |
|
| 42 |
+ } |
|
| 43 |
+ }; |
|
| 44 |
+ let certificate_chain = certificate_chain_der(&signed_data)?; |
|
| 45 |
+ |
|
| 46 |
+ Ok(Self {
|
|
| 47 |
+ plist, |
|
| 48 |
+ team_id, |
|
| 49 |
+ entitlements, |
|
| 50 |
+ certificate_chain, |
|
| 51 |
+ }) |
|
| 52 |
+ } |
|
| 53 |
+ |
|
| 54 |
+ pub fn plist(&self) -> &Dictionary {
|
|
| 55 |
+ &self.plist |
|
| 56 |
+ } |
|
| 57 |
+ |
|
| 58 |
+ pub fn team_id(&self) -> &str {
|
|
| 59 |
+ &self.team_id |
|
| 60 |
+ } |
|
| 61 |
+ |
|
| 62 |
+ pub fn entitlements(&self) -> &Dictionary {
|
|
| 63 |
+ &self.entitlements |
|
| 64 |
+ } |
|
| 65 |
+ |
|
| 66 |
+ pub fn certificate_chain_der(&self) -> &[Vec<u8>] {
|
|
| 67 |
+ &self.certificate_chain |
|
| 68 |
+ } |
|
| 69 |
+} |
|
| 70 |
+ |
|
| 71 |
+fn decode_signed_data(data: &[u8]) -> Result<SignedData> {
|
|
| 72 |
+ let content_info = ContentInfo::from_der(data).map_err(|err| {
|
|
| 73 |
+ CodeSignError::provisioning_profile(format!("failed to decode CMS ContentInfo: {err}"))
|
|
| 74 |
+ })?; |
|
| 75 |
+ if content_info.content_type != const_oid::db::rfc5911::ID_SIGNED_DATA {
|
|
| 76 |
+ return Err(CodeSignError::provisioning_profile( |
|
| 77 |
+ "CMS ContentInfo is not SignedData", |
|
| 78 |
+ )); |
|
| 79 |
+ } |
|
| 80 |
+ |
|
| 81 |
+ let signed_data = content_info.content.to_der().map_err(|err| {
|
|
| 82 |
+ CodeSignError::provisioning_profile(format!("failed to re-encode SignedData: {err}"))
|
|
| 83 |
+ })?; |
|
| 84 |
+ SignedData::from_der(&signed_data).map_err(|err| {
|
|
| 85 |
+ CodeSignError::provisioning_profile(format!("failed to decode CMS SignedData: {err}"))
|
|
| 86 |
+ }) |
|
| 87 |
+} |
|
| 88 |
+ |
|
| 89 |
+fn team_identifier(plist: &Dictionary) -> Result<String> {
|
|
| 90 |
+ plist |
|
| 91 |
+ .get("TeamIdentifier")
|
|
| 92 |
+ .and_then(Value::as_array) |
|
| 93 |
+ .and_then(|values| values.first()) |
|
| 94 |
+ .and_then(Value::as_string) |
|
| 95 |
+ .map(ToString::to_string) |
|
| 96 |
+ .or_else(|| {
|
|
| 97 |
+ plist |
|
| 98 |
+ .get("ApplicationIdentifierPrefix")
|
|
| 99 |
+ .and_then(Value::as_array) |
|
| 100 |
+ .and_then(|values| values.first()) |
|
| 101 |
+ .and_then(Value::as_string) |
|
| 102 |
+ .map(ToString::to_string) |
|
| 103 |
+ }) |
|
| 104 |
+ .ok_or_else(|| {
|
|
| 105 |
+ CodeSignError::provisioning_profile( |
|
| 106 |
+ "profile is missing TeamIdentifier/ApplicationIdentifierPrefix", |
|
| 107 |
+ ) |
|
| 108 |
+ }) |
|
| 109 |
+} |
|
| 110 |
+ |
|
| 111 |
+fn certificate_chain_der(signed_data: &SignedData) -> Result<Vec<Vec<u8>>> {
|
|
| 112 |
+ let Some(certificates) = &signed_data.certificates else {
|
|
| 113 |
+ return Ok(Vec::new()); |
|
| 114 |
+ }; |
|
| 115 |
+ let mut out = Vec::with_capacity(certificates.0.len()); |
|
| 116 |
+ |
|
| 117 |
+ for certificate in certificates.0.iter() {
|
|
| 118 |
+ if let CertificateChoices::Certificate(certificate) = certificate {
|
|
| 119 |
+ out.push(certificate.to_der().map_err(|err| {
|
|
| 120 |
+ CodeSignError::provisioning_profile(format!( |
|
| 121 |
+ "failed to encode CMS certificate: {err}"
|
|
| 122 |
+ )) |
|
| 123 |
+ })?); |
|
| 124 |
+ } |
|
| 125 |
+ } |
|
| 126 |
+ |
|
| 127 |
+ Ok(out) |
|
| 128 |
+} |
| ... | ... |
@@ -0,0 +1,467 @@ |
| 1 |
+use crate::error::{CodeSignError, Result};
|
|
| 2 |
+use crate::signature::{CmsSigner, CmsSigningRequest, HashAlgorithm};
|
|
| 3 |
+use cms::builder::{SignedDataBuilder, SignerInfoBuilder};
|
|
| 4 |
+use cms::cert::{CertificateChoices, IssuerAndSerialNumber};
|
|
| 5 |
+use cms::signed_data::{EncapsulatedContentInfo, SignerIdentifier};
|
|
| 6 |
+use const_oid::ObjectIdentifier; |
|
| 7 |
+use der::asn1::{Any, ObjectIdentifier as DerObjectIdentifier, OctetStringRef, SetOfVec};
|
|
| 8 |
+use der::{DateTime, Decode, Encode, Sequence};
|
|
| 9 |
+use plist::{Dictionary, Value};
|
|
| 10 |
+use rsa::RsaPrivateKey; |
|
| 11 |
+use rsa::pkcs1::DecodeRsaPrivateKey; |
|
| 12 |
+use rsa::pkcs1v15::SigningKey; |
|
| 13 |
+use rsa::pkcs8::DecodePrivateKey; |
|
| 14 |
+use spki::AlgorithmIdentifierOwned; |
|
| 15 |
+use std::fmt::Display; |
|
| 16 |
+use std::time::SystemTime; |
|
| 17 |
+use x509_cert::Certificate; |
|
| 18 |
+use x509_cert::attr::{Attribute, AttributeValue};
|
|
| 19 |
+ |
|
| 20 |
+const APPLE_CODE_DIRECTORY_HASHES_OID: ObjectIdentifier = |
|
| 21 |
+ ObjectIdentifier::new_unwrap("1.2.840.113635.100.9.1");
|
|
| 22 |
+const APPLE_CODE_DIRECTORY_DIGEST_OID: ObjectIdentifier = |
|
| 23 |
+ ObjectIdentifier::new_unwrap("1.2.840.113635.100.9.2");
|
|
| 24 |
+const APPLE_WWDR_G3_CERTIFICATE_DER: &[u8] = include_bytes!("../assets/AppleWWDRCAG3.cer");
|
|
| 25 |
+const APPLE_ROOT_CERTIFICATE_DER: &[u8] = include_bytes!("../assets/AppleIncRootCertificate.cer");
|
|
| 26 |
+ |
|
| 27 |
+type RsaSha256SigningKey = SigningKey<sha2_010::Sha256>; |
|
| 28 |
+ |
|
| 29 |
+/// CMS signer for Apple Code Signing using RustCrypto's CMS/DER/X.509 crates. |
|
| 30 |
+/// |
|
| 31 |
+/// The generated CMS is detached SignedData over the primary CodeDirectory and |
|
| 32 |
+/// includes the Apple-specific CodeDirectory digest attributes that AMFI expects. |
|
| 33 |
+pub struct RustCryptoCmsSigner {
|
|
| 34 |
+ signing_key: RsaSha256SigningKey, |
|
| 35 |
+ signer_certificate: Certificate, |
|
| 36 |
+ certificate_chain: Vec<Certificate>, |
|
| 37 |
+ signing_time: Option<SystemTime>, |
|
| 38 |
+} |
|
| 39 |
+ |
|
| 40 |
+impl RustCryptoCmsSigner {
|
|
| 41 |
+ pub fn new( |
|
| 42 |
+ private_key: RsaPrivateKey, |
|
| 43 |
+ signer_certificate: Certificate, |
|
| 44 |
+ certificate_chain: Vec<Certificate>, |
|
| 45 |
+ ) -> Self {
|
|
| 46 |
+ Self {
|
|
| 47 |
+ signing_key: RsaSha256SigningKey::new(private_key), |
|
| 48 |
+ signer_certificate, |
|
| 49 |
+ certificate_chain, |
|
| 50 |
+ signing_time: Some(SystemTime::now()), |
|
| 51 |
+ } |
|
| 52 |
+ } |
|
| 53 |
+ |
|
| 54 |
+ pub fn from_der<I, C>( |
|
| 55 |
+ private_key_der: &[u8], |
|
| 56 |
+ signer_certificate_der: &[u8], |
|
| 57 |
+ certificate_chain_der: I, |
|
| 58 |
+ ) -> Result<Self> |
|
| 59 |
+ where |
|
| 60 |
+ I: IntoIterator<Item = C>, |
|
| 61 |
+ C: AsRef<[u8]>, |
|
| 62 |
+ {
|
|
| 63 |
+ let private_key = decode_rsa_private_key(private_key_der)?; |
|
| 64 |
+ let signer_certificate = Certificate::from_der(signer_certificate_der) |
|
| 65 |
+ .map_err(|err| cms_error(format!("failed to decode signer certificate: {err}")))?;
|
|
| 66 |
+ let mut certificate_candidates = Vec::new(); |
|
| 67 |
+ |
|
| 68 |
+ for certificate_der in certificate_chain_der {
|
|
| 69 |
+ let certificate = Certificate::from_der(certificate_der.as_ref()) |
|
| 70 |
+ .map_err(|err| cms_error(format!("failed to decode chain certificate: {err}")))?;
|
|
| 71 |
+ push_unique_certificate(&mut certificate_candidates, certificate); |
|
| 72 |
+ } |
|
| 73 |
+ |
|
| 74 |
+ for (name, certificate_der) in [ |
|
| 75 |
+ ("Apple WWDR G3", APPLE_WWDR_G3_CERTIFICATE_DER),
|
|
| 76 |
+ ("Apple Root CA", APPLE_ROOT_CERTIFICATE_DER),
|
|
| 77 |
+ ] {
|
|
| 78 |
+ let certificate = Certificate::from_der(certificate_der).map_err(|err| {
|
|
| 79 |
+ cms_error(format!( |
|
| 80 |
+ "failed to decode bundled {name} certificate: {err}"
|
|
| 81 |
+ )) |
|
| 82 |
+ })?; |
|
| 83 |
+ push_unique_certificate(&mut certificate_candidates, certificate); |
|
| 84 |
+ } |
|
| 85 |
+ |
|
| 86 |
+ let certificate_chain = |
|
| 87 |
+ certificate_chain_for_signer(&signer_certificate, &certificate_candidates)?; |
|
| 88 |
+ |
|
| 89 |
+ Ok(Self::new( |
|
| 90 |
+ private_key, |
|
| 91 |
+ signer_certificate, |
|
| 92 |
+ certificate_chain, |
|
| 93 |
+ )) |
|
| 94 |
+ } |
|
| 95 |
+ |
|
| 96 |
+ pub fn with_signing_time(mut self, signing_time: SystemTime) -> Self {
|
|
| 97 |
+ self.signing_time = Some(signing_time); |
|
| 98 |
+ self |
|
| 99 |
+ } |
|
| 100 |
+ |
|
| 101 |
+ pub fn without_signing_time(mut self) -> Self {
|
|
| 102 |
+ self.signing_time = None; |
|
| 103 |
+ self |
|
| 104 |
+ } |
|
| 105 |
+ |
|
| 106 |
+ pub fn signer_certificate(&self) -> &Certificate {
|
|
| 107 |
+ &self.signer_certificate |
|
| 108 |
+ } |
|
| 109 |
+ |
|
| 110 |
+ pub fn certificate_chain(&self) -> &[Certificate] {
|
|
| 111 |
+ &self.certificate_chain |
|
| 112 |
+ } |
|
| 113 |
+} |
|
| 114 |
+ |
|
| 115 |
+impl CmsSigner for RustCryptoCmsSigner {
|
|
| 116 |
+ fn sign(&self, request: CmsSigningRequest<'_>) -> Result<Vec<u8>> {
|
|
| 117 |
+ let primary_code_directory = request |
|
| 118 |
+ .code_directories |
|
| 119 |
+ .first() |
|
| 120 |
+ .ok_or_else(|| cms_error("cannot build CMS without a primary CodeDirectory"))?;
|
|
| 121 |
+ let message_digest = HashAlgorithm::Sha256.digest(primary_code_directory.bytes); |
|
| 122 |
+ |
|
| 123 |
+ let encapsulated_content_info = EncapsulatedContentInfo {
|
|
| 124 |
+ econtent_type: const_oid::db::rfc5911::ID_DATA, |
|
| 125 |
+ econtent: None, |
|
| 126 |
+ }; |
|
| 127 |
+ let digest_algorithm = sha256_algorithm_identifier(); |
|
| 128 |
+ let signer_identifier = signer_identifier(&self.signer_certificate); |
|
| 129 |
+ let mut signer_info = SignerInfoBuilder::new( |
|
| 130 |
+ &self.signing_key, |
|
| 131 |
+ signer_identifier, |
|
| 132 |
+ digest_algorithm.clone(), |
|
| 133 |
+ &encapsulated_content_info, |
|
| 134 |
+ Some(&message_digest), |
|
| 135 |
+ ) |
|
| 136 |
+ .map_err(cms_builder_error)?; |
|
| 137 |
+ |
|
| 138 |
+ if let Some(signing_time) = self.signing_time {
|
|
| 139 |
+ signer_info |
|
| 140 |
+ .add_signed_attribute(signing_time_attribute(signing_time)?) |
|
| 141 |
+ .map_err(cms_builder_error)?; |
|
| 142 |
+ } |
|
| 143 |
+ |
|
| 144 |
+ signer_info |
|
| 145 |
+ .add_signed_attribute(apple_code_directory_digest_attribute(&request)?) |
|
| 146 |
+ .map_err(cms_builder_error)?; |
|
| 147 |
+ signer_info |
|
| 148 |
+ .add_signed_attribute(apple_code_directory_hashes_attribute(&request)?) |
|
| 149 |
+ .map_err(cms_builder_error)?; |
|
| 150 |
+ |
|
| 151 |
+ let mut builder = SignedDataBuilder::new(&encapsulated_content_info); |
|
| 152 |
+ builder |
|
| 153 |
+ .add_digest_algorithm(digest_algorithm) |
|
| 154 |
+ .map_err(cms_builder_error)?; |
|
| 155 |
+ add_certificates( |
|
| 156 |
+ &mut builder, |
|
| 157 |
+ &self.signer_certificate, |
|
| 158 |
+ &self.certificate_chain, |
|
| 159 |
+ )?; |
|
| 160 |
+ builder |
|
| 161 |
+ .add_signer_info::<RsaSha256SigningKey, rsa::pkcs1v15::Signature>(signer_info) |
|
| 162 |
+ .map_err(cms_builder_error)?; |
|
| 163 |
+ |
|
| 164 |
+ let content_info = builder.build().map_err(cms_builder_error)?; |
|
| 165 |
+ content_info.to_der().map_err(cms_der_error) |
|
| 166 |
+ } |
|
| 167 |
+} |
|
| 168 |
+ |
|
| 169 |
+#[derive(Sequence)] |
|
| 170 |
+struct AppleCodeDirectoryDigest<'a> {
|
|
| 171 |
+ algorithm: DerObjectIdentifier, |
|
| 172 |
+ digest: OctetStringRef<'a>, |
|
| 173 |
+} |
|
| 174 |
+ |
|
| 175 |
+fn decode_rsa_private_key(der: &[u8]) -> Result<RsaPrivateKey> {
|
|
| 176 |
+ match RsaPrivateKey::from_pkcs8_der(der) {
|
|
| 177 |
+ Ok(private_key) => Ok(private_key), |
|
| 178 |
+ Err(pkcs8_error) => RsaPrivateKey::from_pkcs1_der(der).map_err(|pkcs1_error| {
|
|
| 179 |
+ cms_error(format!( |
|
| 180 |
+ "failed to decode RSA private key as PKCS#8 ({pkcs8_error}) or PKCS#1 ({pkcs1_error})"
|
|
| 181 |
+ )) |
|
| 182 |
+ }), |
|
| 183 |
+ } |
|
| 184 |
+} |
|
| 185 |
+ |
|
| 186 |
+fn push_unique_certificate(certificates: &mut Vec<Certificate>, certificate: Certificate) {
|
|
| 187 |
+ if !certificates.contains(&certificate) {
|
|
| 188 |
+ certificates.push(certificate); |
|
| 189 |
+ } |
|
| 190 |
+} |
|
| 191 |
+ |
|
| 192 |
+fn certificate_chain_for_signer( |
|
| 193 |
+ signer_certificate: &Certificate, |
|
| 194 |
+ certificate_candidates: &[Certificate], |
|
| 195 |
+) -> Result<Vec<Certificate>> {
|
|
| 196 |
+ if signer_certificate.tbs_certificate.subject == signer_certificate.tbs_certificate.issuer {
|
|
| 197 |
+ return Ok(Vec::new()); |
|
| 198 |
+ } |
|
| 199 |
+ |
|
| 200 |
+ let mut issuer = signer_certificate.tbs_certificate.issuer.clone(); |
|
| 201 |
+ let mut certificate_chain = Vec::new(); |
|
| 202 |
+ |
|
| 203 |
+ loop {
|
|
| 204 |
+ let certificate = certificate_candidates |
|
| 205 |
+ .iter() |
|
| 206 |
+ .find(|candidate| candidate.tbs_certificate.subject == issuer) |
|
| 207 |
+ .ok_or_else(|| {
|
|
| 208 |
+ cms_error(format!( |
|
| 209 |
+ "missing issuer certificate {issuer} for the code-signing certificate chain"
|
|
| 210 |
+ )) |
|
| 211 |
+ })? |
|
| 212 |
+ .clone(); |
|
| 213 |
+ |
|
| 214 |
+ if certificate_chain.contains(&certificate) {
|
|
| 215 |
+ return Err(cms_error("certificate chain contains a cycle"));
|
|
| 216 |
+ } |
|
| 217 |
+ |
|
| 218 |
+ let is_root = certificate.tbs_certificate.subject == certificate.tbs_certificate.issuer; |
|
| 219 |
+ issuer = certificate.tbs_certificate.issuer.clone(); |
|
| 220 |
+ certificate_chain.push(certificate); |
|
| 221 |
+ |
|
| 222 |
+ if is_root {
|
|
| 223 |
+ return Ok(certificate_chain); |
|
| 224 |
+ } |
|
| 225 |
+ } |
|
| 226 |
+} |
|
| 227 |
+ |
|
| 228 |
+fn add_certificates( |
|
| 229 |
+ builder: &mut SignedDataBuilder<'_>, |
|
| 230 |
+ signer_certificate: &Certificate, |
|
| 231 |
+ certificate_chain: &[Certificate], |
|
| 232 |
+) -> Result<()> {
|
|
| 233 |
+ let mut added = Vec::with_capacity(certificate_chain.len() + 1); |
|
| 234 |
+ |
|
| 235 |
+ for certificate in certificate_chain |
|
| 236 |
+ .iter() |
|
| 237 |
+ .chain(std::iter::once(signer_certificate)) |
|
| 238 |
+ {
|
|
| 239 |
+ if added.contains(&certificate) {
|
|
| 240 |
+ continue; |
|
| 241 |
+ } |
|
| 242 |
+ |
|
| 243 |
+ builder |
|
| 244 |
+ .add_certificate(CertificateChoices::Certificate(certificate.clone())) |
|
| 245 |
+ .map_err(cms_builder_error)?; |
|
| 246 |
+ added.push(certificate); |
|
| 247 |
+ } |
|
| 248 |
+ |
|
| 249 |
+ Ok(()) |
|
| 250 |
+} |
|
| 251 |
+ |
|
| 252 |
+fn signing_time_attribute(signing_time: SystemTime) -> Result<Attribute> {
|
|
| 253 |
+ let date_time = DateTime::from_system_time(signing_time).map_err(cms_der_error)?; |
|
| 254 |
+ let time_der = if date_time.year() < 1950 || date_time.year() > 2049 {
|
|
| 255 |
+ der::asn1::GeneralizedTime::from_date_time(date_time) |
|
| 256 |
+ .to_der() |
|
| 257 |
+ .map_err(cms_der_error)? |
|
| 258 |
+ } else {
|
|
| 259 |
+ der::asn1::UtcTime::from_date_time(date_time) |
|
| 260 |
+ .map_err(cms_der_error)? |
|
| 261 |
+ .to_der() |
|
| 262 |
+ .map_err(cms_der_error)? |
|
| 263 |
+ }; |
|
| 264 |
+ let value = AttributeValue::from_der(&time_der).map_err(cms_der_error)?; |
|
| 265 |
+ |
|
| 266 |
+ single_value_attribute(const_oid::db::rfc5911::ID_SIGNING_TIME, value) |
|
| 267 |
+} |
|
| 268 |
+ |
|
| 269 |
+fn signer_identifier(certificate: &Certificate) -> SignerIdentifier {
|
|
| 270 |
+ SignerIdentifier::IssuerAndSerialNumber(IssuerAndSerialNumber {
|
|
| 271 |
+ issuer: certificate.tbs_certificate.issuer.clone(), |
|
| 272 |
+ serial_number: certificate.tbs_certificate.serial_number.clone(), |
|
| 273 |
+ }) |
|
| 274 |
+} |
|
| 275 |
+ |
|
| 276 |
+fn apple_code_directory_digest_attribute(request: &CmsSigningRequest<'_>) -> Result<Attribute> {
|
|
| 277 |
+ if !request |
|
| 278 |
+ .code_directories |
|
| 279 |
+ .iter() |
|
| 280 |
+ .any(|code_directory| code_directory.algorithm == HashAlgorithm::Sha256) |
|
| 281 |
+ {
|
|
| 282 |
+ return Err(cms_error( |
|
| 283 |
+ "cannot build CMS without a SHA-256 CodeDirectory", |
|
| 284 |
+ )); |
|
| 285 |
+ } |
|
| 286 |
+ |
|
| 287 |
+ let mut values = Vec::with_capacity(request.code_directories.len()); |
|
| 288 |
+ for code_directory in request.code_directories {
|
|
| 289 |
+ let digest = code_directory.algorithm.digest(code_directory.bytes); |
|
| 290 |
+ let digest = OctetStringRef::new(&digest).map_err(cms_der_error)?; |
|
| 291 |
+ values.push( |
|
| 292 |
+ Any::encode_from(&AppleCodeDirectoryDigest {
|
|
| 293 |
+ algorithm: match code_directory.algorithm {
|
|
| 294 |
+ HashAlgorithm::Sha1 => const_oid::db::rfc5912::ID_SHA_1, |
|
| 295 |
+ HashAlgorithm::Sha256 => const_oid::db::rfc5912::ID_SHA_256, |
|
| 296 |
+ }, |
|
| 297 |
+ digest, |
|
| 298 |
+ }) |
|
| 299 |
+ .map_err(cms_der_error)?, |
|
| 300 |
+ ); |
|
| 301 |
+ } |
|
| 302 |
+ |
|
| 303 |
+ Ok(Attribute {
|
|
| 304 |
+ oid: APPLE_CODE_DIRECTORY_DIGEST_OID, |
|
| 305 |
+ values: SetOfVec::try_from(values).map_err(cms_der_error)?, |
|
| 306 |
+ }) |
|
| 307 |
+} |
|
| 308 |
+ |
|
| 309 |
+fn apple_code_directory_hashes_attribute(request: &CmsSigningRequest<'_>) -> Result<Attribute> {
|
|
| 310 |
+ let mut cdhashes = Vec::with_capacity(request.code_directories.len()); |
|
| 311 |
+ |
|
| 312 |
+ for code_directory in request.code_directories {
|
|
| 313 |
+ let digest = code_directory.algorithm.digest(code_directory.bytes); |
|
| 314 |
+ let cdhash = digest |
|
| 315 |
+ .get(..20) |
|
| 316 |
+ .ok_or_else(|| cms_error("CodeDirectory digest is shorter than a cdhash"))?;
|
|
| 317 |
+ cdhashes.push(Value::Data(cdhash.to_vec())); |
|
| 318 |
+ } |
|
| 319 |
+ |
|
| 320 |
+ let mut plist = Dictionary::new(); |
|
| 321 |
+ plist.insert("cdhashes".to_string(), Value::Array(cdhashes));
|
|
| 322 |
+ |
|
| 323 |
+ let mut xml = Vec::new(); |
|
| 324 |
+ plist::to_writer_xml(&mut xml, &Value::Dictionary(plist))?; |
|
| 325 |
+ if xml.last() != Some(&b'\n') {
|
|
| 326 |
+ xml.push(b'\n'); |
|
| 327 |
+ } |
|
| 328 |
+ |
|
| 329 |
+ let value = Any::from(OctetStringRef::new(&xml).map_err(cms_der_error)?); |
|
| 330 |
+ single_value_attribute(APPLE_CODE_DIRECTORY_HASHES_OID, value) |
|
| 331 |
+} |
|
| 332 |
+ |
|
| 333 |
+fn single_value_attribute(oid: DerObjectIdentifier, value: AttributeValue) -> Result<Attribute> {
|
|
| 334 |
+ Ok(Attribute {
|
|
| 335 |
+ oid, |
|
| 336 |
+ values: SetOfVec::try_from(vec![value]).map_err(cms_der_error)?, |
|
| 337 |
+ }) |
|
| 338 |
+} |
|
| 339 |
+ |
|
| 340 |
+fn sha256_algorithm_identifier() -> AlgorithmIdentifierOwned {
|
|
| 341 |
+ AlgorithmIdentifierOwned {
|
|
| 342 |
+ oid: const_oid::db::rfc5912::ID_SHA_256, |
|
| 343 |
+ parameters: Some(Any::null()), |
|
| 344 |
+ } |
|
| 345 |
+} |
|
| 346 |
+ |
|
| 347 |
+fn cms_builder_error(error: cms::builder::Error) -> CodeSignError {
|
|
| 348 |
+ cms_error(error) |
|
| 349 |
+} |
|
| 350 |
+ |
|
| 351 |
+fn cms_der_error(error: der::Error) -> CodeSignError {
|
|
| 352 |
+ cms_error(error) |
|
| 353 |
+} |
|
| 354 |
+ |
|
| 355 |
+fn cms_error(message: impl Display) -> CodeSignError {
|
|
| 356 |
+ CodeSignError::cms(message.to_string()) |
|
| 357 |
+} |
|
| 358 |
+ |
|
| 359 |
+#[cfg(test)] |
|
| 360 |
+mod tests {
|
|
| 361 |
+ use super::*; |
|
| 362 |
+ use crate::signature::EncodedCodeDirectory; |
|
| 363 |
+ use der::Decode; |
|
| 364 |
+ |
|
| 365 |
+ #[test] |
|
| 366 |
+ fn apple_code_directory_digest_requires_sha256_code_directory() {
|
|
| 367 |
+ let code_directory = EncodedCodeDirectory {
|
|
| 368 |
+ algorithm: HashAlgorithm::Sha1, |
|
| 369 |
+ bytes: b"primary", |
|
| 370 |
+ }; |
|
| 371 |
+ let request = CmsSigningRequest {
|
|
| 372 |
+ code_directories: &[code_directory], |
|
| 373 |
+ }; |
|
| 374 |
+ |
|
| 375 |
+ assert!(apple_code_directory_digest_attribute(&request).is_err()); |
|
| 376 |
+ } |
|
| 377 |
+ |
|
| 378 |
+ #[test] |
|
| 379 |
+ fn apple_code_directory_digest_contains_a_bare_hash_oid() {
|
|
| 380 |
+ let code_directory = EncodedCodeDirectory {
|
|
| 381 |
+ algorithm: HashAlgorithm::Sha256, |
|
| 382 |
+ bytes: b"alternate", |
|
| 383 |
+ }; |
|
| 384 |
+ let request = CmsSigningRequest {
|
|
| 385 |
+ code_directories: &[code_directory], |
|
| 386 |
+ }; |
|
| 387 |
+ |
|
| 388 |
+ let attribute = apple_code_directory_digest_attribute(&request).unwrap(); |
|
| 389 |
+ let value_der = attribute.values.get(0).unwrap().to_der().unwrap(); |
|
| 390 |
+ |
|
| 391 |
+ assert_eq!( |
|
| 392 |
+ &value_der[..15], |
|
| 393 |
+ &[ |
|
| 394 |
+ 0x30, 0x2d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x04, |
|
| 395 |
+ 0x20, |
|
| 396 |
+ ] |
|
| 397 |
+ ); |
|
| 398 |
+ assert_eq!(value_der.len(), 47); |
|
| 399 |
+ } |
|
| 400 |
+ |
|
| 401 |
+ #[test] |
|
| 402 |
+ fn apple_code_directory_hashes_are_xml_plist_octet_string() {
|
|
| 403 |
+ let code_directories = [ |
|
| 404 |
+ EncodedCodeDirectory {
|
|
| 405 |
+ algorithm: HashAlgorithm::Sha1, |
|
| 406 |
+ bytes: b"primary", |
|
| 407 |
+ }, |
|
| 408 |
+ EncodedCodeDirectory {
|
|
| 409 |
+ algorithm: HashAlgorithm::Sha256, |
|
| 410 |
+ bytes: b"alternate", |
|
| 411 |
+ }, |
|
| 412 |
+ ]; |
|
| 413 |
+ let request = CmsSigningRequest {
|
|
| 414 |
+ code_directories: &code_directories, |
|
| 415 |
+ }; |
|
| 416 |
+ |
|
| 417 |
+ let attribute = apple_code_directory_hashes_attribute(&request).unwrap(); |
|
| 418 |
+ assert_eq!(attribute.oid, APPLE_CODE_DIRECTORY_HASHES_OID); |
|
| 419 |
+ |
|
| 420 |
+ let value = attribute.values.get(0).unwrap(); |
|
| 421 |
+ let plist_xml = OctetStringRef::from_der(value.to_der().unwrap().as_slice()) |
|
| 422 |
+ .unwrap() |
|
| 423 |
+ .as_bytes() |
|
| 424 |
+ .to_vec(); |
|
| 425 |
+ assert!(plist_xml.starts_with(br#"<?xml version="1.0""#)); |
|
| 426 |
+ assert!(plist_xml.ends_with(b"\n")); |
|
| 427 |
+ } |
|
| 428 |
+ |
|
| 429 |
+ #[test] |
|
| 430 |
+ fn apple_code_directory_digest_includes_every_code_directory() {
|
|
| 431 |
+ let code_directories = [ |
|
| 432 |
+ EncodedCodeDirectory {
|
|
| 433 |
+ algorithm: HashAlgorithm::Sha1, |
|
| 434 |
+ bytes: b"primary", |
|
| 435 |
+ }, |
|
| 436 |
+ EncodedCodeDirectory {
|
|
| 437 |
+ algorithm: HashAlgorithm::Sha256, |
|
| 438 |
+ bytes: b"alternate", |
|
| 439 |
+ }, |
|
| 440 |
+ ]; |
|
| 441 |
+ let request = CmsSigningRequest {
|
|
| 442 |
+ code_directories: &code_directories, |
|
| 443 |
+ }; |
|
| 444 |
+ |
|
| 445 |
+ let attribute = apple_code_directory_digest_attribute(&request).unwrap(); |
|
| 446 |
+ let sha1_value_der = attribute.values.get(0).unwrap().to_der().unwrap(); |
|
| 447 |
+ |
|
| 448 |
+ assert_eq!(attribute.values.len(), 2); |
|
| 449 |
+ assert_eq!( |
|
| 450 |
+ &sha1_value_der[..11], |
|
| 451 |
+ &[ |
|
| 452 |
+ 0x30, 0x1d, 0x06, 0x05, 0x2b, 0x0e, 0x03, 0x02, 0x1a, 0x04, 0x14 |
|
| 453 |
+ ] |
|
| 454 |
+ ); |
|
| 455 |
+ } |
|
| 456 |
+ |
|
| 457 |
+ #[test] |
|
| 458 |
+ fn bundled_apple_chain_links_wwdr_g3_to_root() {
|
|
| 459 |
+ let wwdr = Certificate::from_der(APPLE_WWDR_G3_CERTIFICATE_DER).unwrap(); |
|
| 460 |
+ let root = Certificate::from_der(APPLE_ROOT_CERTIFICATE_DER).unwrap(); |
|
| 461 |
+ let candidates = [wwdr.clone(), root.clone()]; |
|
| 462 |
+ |
|
| 463 |
+ let chain = certificate_chain_for_signer(&wwdr, &candidates).unwrap(); |
|
| 464 |
+ |
|
| 465 |
+ assert_eq!(chain, vec![root]); |
|
| 466 |
+ } |
|
| 467 |
+} |
| ... | ... |
@@ -0,0 +1,696 @@ |
| 1 |
+use crate::error::{CodeSignError, Result};
|
|
| 2 |
+use plist::{Dictionary, Value};
|
|
| 3 |
+use rayon::prelude::*; |
|
| 4 |
+use sha1::Digest as _; |
|
| 5 |
+use std::borrow::Cow; |
|
| 6 |
+ |
|
| 7 |
+pub const CSSLOT_CODEDIRECTORY: u32 = 0; |
|
| 8 |
+pub const CSSLOT_REQUIREMENTS: u32 = 2; |
|
| 9 |
+pub const CSSLOT_ENTITLEMENTS: u32 = 5; |
|
| 10 |
+pub const CSSLOT_DER_ENTITLEMENTS: u32 = 7; |
|
| 11 |
+pub const CSSLOT_ALTERNATE_CODEDIRECTORIES: u32 = 0x1000; |
|
| 12 |
+pub const CSSLOT_SIGNATURESLOT: u32 = 0x10000; |
|
| 13 |
+ |
|
| 14 |
+pub const CSMAGIC_BLOBWRAPPER: u32 = 0xfade_0b01; |
|
| 15 |
+pub const CSMAGIC_REQUIREMENTS: u32 = 0xfade_0c01; |
|
| 16 |
+pub const CSMAGIC_CODEDIRECTORY: u32 = 0xfade_0c02; |
|
| 17 |
+pub const CSMAGIC_EMBEDDED_SIGNATURE: u32 = 0xfade_0cc0; |
|
| 18 |
+pub const CSMAGIC_EMBEDDED_ENTITLEMENTS: u32 = 0xfade_7171; |
|
| 19 |
+pub const CSMAGIC_EMBEDDED_DER_ENTITLEMENTS: u32 = 0xfade_7172; |
|
| 20 |
+ |
|
| 21 |
+const CODEDIRECTORY_VERSION_EXECSEG: u32 = 0x20400; |
|
| 22 |
+const CODEDIRECTORY_HEADER_LEN: usize = 88; |
|
| 23 |
+const CODEDIRECTORY_PAGE_SIZE_LOG2: u8 = 12; |
|
| 24 |
+const CODEDIRECTORY_PAGE_SIZE: usize = 1 << CODEDIRECTORY_PAGE_SIZE_LOG2; |
|
| 25 |
+const CODEDIRECTORY_PARALLEL_HASH_PAGES: usize = 64; |
|
| 26 |
+ |
|
| 27 |
+pub const CS_EXECSEG_MAIN_BINARY: u64 = 0x1; |
|
| 28 |
+pub const CS_EXECSEG_ALLOW_UNSIGNED: u64 = 0x10; |
|
| 29 |
+pub const CS_EXECSEG_DEBUGGER: u64 = 0x20; |
|
| 30 |
+pub const CS_EXECSEG_JIT: u64 = 0x40; |
|
| 31 |
+pub const CS_EXECSEG_SKIP_LV: u64 = 0x80; |
|
| 32 |
+pub const CS_EXECSEG_CAN_LOAD_CDHASH: u64 = 0x100; |
|
| 33 |
+pub const CS_EXECSEG_CAN_EXEC_CDHASH: u64 = 0x200; |
|
| 34 |
+ |
|
| 35 |
+#[derive(Clone, Copy, Debug, Eq, PartialEq)] |
|
| 36 |
+pub enum HashAlgorithm {
|
|
| 37 |
+ Sha1, |
|
| 38 |
+ Sha256, |
|
| 39 |
+} |
|
| 40 |
+ |
|
| 41 |
+impl HashAlgorithm {
|
|
| 42 |
+ pub fn digest(self, bytes: &[u8]) -> Vec<u8> {
|
|
| 43 |
+ match self {
|
|
| 44 |
+ Self::Sha1 => sha1::Sha1::digest(bytes).to_vec(), |
|
| 45 |
+ Self::Sha256 => sha2::Sha256::digest(bytes).to_vec(), |
|
| 46 |
+ } |
|
| 47 |
+ } |
|
| 48 |
+ |
|
| 49 |
+ fn push_digest(self, out: &mut Vec<u8>, bytes: &[u8]) {
|
|
| 50 |
+ match self {
|
|
| 51 |
+ Self::Sha1 => {
|
|
| 52 |
+ let digest = sha1::Sha1::digest(bytes); |
|
| 53 |
+ out.extend_from_slice(&digest); |
|
| 54 |
+ } |
|
| 55 |
+ Self::Sha256 => {
|
|
| 56 |
+ let digest = sha2::Sha256::digest(bytes); |
|
| 57 |
+ out.extend_from_slice(&digest); |
|
| 58 |
+ } |
|
| 59 |
+ } |
|
| 60 |
+ } |
|
| 61 |
+ |
|
| 62 |
+ pub fn digest_len(self) -> usize {
|
|
| 63 |
+ match self {
|
|
| 64 |
+ Self::Sha1 => 20, |
|
| 65 |
+ Self::Sha256 => 32, |
|
| 66 |
+ } |
|
| 67 |
+ } |
|
| 68 |
+ |
|
| 69 |
+ pub fn code_directory_hash_type(self) -> u8 {
|
|
| 70 |
+ match self {
|
|
| 71 |
+ Self::Sha1 => 1, |
|
| 72 |
+ Self::Sha256 => 2, |
|
| 73 |
+ } |
|
| 74 |
+ } |
|
| 75 |
+} |
|
| 76 |
+ |
|
| 77 |
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] |
|
| 78 |
+pub struct ExecutableSegment {
|
|
| 79 |
+ pub base: u64, |
|
| 80 |
+ pub limit: u64, |
|
| 81 |
+ pub flags: u64, |
|
| 82 |
+} |
|
| 83 |
+ |
|
| 84 |
+#[derive(Debug)] |
|
| 85 |
+pub struct EncodedCodeDirectory<'a> {
|
|
| 86 |
+ pub algorithm: HashAlgorithm, |
|
| 87 |
+ pub bytes: &'a [u8], |
|
| 88 |
+} |
|
| 89 |
+ |
|
| 90 |
+#[derive(Debug)] |
|
| 91 |
+pub struct CmsSigningRequest<'a> {
|
|
| 92 |
+ pub code_directories: &'a [EncodedCodeDirectory<'a>], |
|
| 93 |
+} |
|
| 94 |
+ |
|
| 95 |
+pub trait CmsSigner: Sync {
|
|
| 96 |
+ fn sign(&self, request: CmsSigningRequest<'_>) -> Result<Vec<u8>>; |
|
| 97 |
+} |
|
| 98 |
+ |
|
| 99 |
+#[derive(Debug)] |
|
| 100 |
+pub struct CodeDirectoryParams<'a> {
|
|
| 101 |
+ pub identifier: &'a str, |
|
| 102 |
+ pub team_id: &'a str, |
|
| 103 |
+ pub macho_data: &'a [u8], |
|
| 104 |
+ pub code_limit: usize, |
|
| 105 |
+ pub executable_segment: ExecutableSegment, |
|
| 106 |
+ pub is_executable: bool, |
|
| 107 |
+ pub requirements_blob: &'a [u8], |
|
| 108 |
+ pub entitlements_blob: &'a [u8], |
|
| 109 |
+ pub der_entitlements_blob: Option<&'a [u8]>, |
|
| 110 |
+ pub info_plist: Option<&'a [u8]>, |
|
| 111 |
+ pub code_resources: Option<&'a [u8]>, |
|
| 112 |
+} |
|
| 113 |
+ |
|
| 114 |
+#[derive(Clone, Copy, Debug, Default)] |
|
| 115 |
+struct CodePageHashes {
|
|
| 116 |
+ sha1: [u8; 20], |
|
| 117 |
+ sha256: [u8; 32], |
|
| 118 |
+} |
|
| 119 |
+ |
|
| 120 |
+impl CodePageHashes {
|
|
| 121 |
+ fn get(&self, algorithm: HashAlgorithm) -> &[u8] {
|
|
| 122 |
+ match algorithm {
|
|
| 123 |
+ HashAlgorithm::Sha1 => &self.sha1, |
|
| 124 |
+ HashAlgorithm::Sha256 => &self.sha256, |
|
| 125 |
+ } |
|
| 126 |
+ } |
|
| 127 |
+} |
|
| 128 |
+ |
|
| 129 |
+pub fn entitlements_xml(entitlements: &Dictionary) -> Result<Vec<u8>> {
|
|
| 130 |
+ let mut data = Vec::new(); |
|
| 131 |
+ plist::to_writer_xml(&mut data, &Value::Dictionary(entitlements.clone()))?; |
|
| 132 |
+ Ok(data) |
|
| 133 |
+} |
|
| 134 |
+ |
|
| 135 |
+pub fn empty_requirements_blob() -> Vec<u8> {
|
|
| 136 |
+ let mut out = Vec::with_capacity(12); |
|
| 137 |
+ push_be_u32(&mut out, CSMAGIC_REQUIREMENTS); |
|
| 138 |
+ push_be_u32(&mut out, 12); |
|
| 139 |
+ push_be_u32(&mut out, 0); |
|
| 140 |
+ out |
|
| 141 |
+} |
|
| 142 |
+ |
|
| 143 |
+pub fn entitlements_blob(xml: &[u8]) -> Vec<u8> {
|
|
| 144 |
+ let mut out = Vec::with_capacity(8 + xml.len()); |
|
| 145 |
+ push_be_u32(&mut out, CSMAGIC_EMBEDDED_ENTITLEMENTS); |
|
| 146 |
+ push_be_u32(&mut out, (8 + xml.len()) as u32); |
|
| 147 |
+ out.extend_from_slice(xml); |
|
| 148 |
+ out |
|
| 149 |
+} |
|
| 150 |
+ |
|
| 151 |
+pub fn der_entitlements_blob(entitlements: &Dictionary) -> Result<Vec<u8>> {
|
|
| 152 |
+ let der = der_entitlements(entitlements)?; |
|
| 153 |
+ let mut out = Vec::with_capacity(8 + der.len()); |
|
| 154 |
+ push_be_u32(&mut out, CSMAGIC_EMBEDDED_DER_ENTITLEMENTS); |
|
| 155 |
+ push_be_u32(&mut out, (8 + der.len()) as u32); |
|
| 156 |
+ out.extend_from_slice(&der); |
|
| 157 |
+ Ok(out) |
|
| 158 |
+} |
|
| 159 |
+ |
|
| 160 |
+pub fn entitlement_exec_flags(entitlements: &Dictionary, is_main_binary: bool) -> u64 {
|
|
| 161 |
+ let mut flags = if is_main_binary {
|
|
| 162 |
+ CS_EXECSEG_MAIN_BINARY |
|
| 163 |
+ } else {
|
|
| 164 |
+ 0 |
|
| 165 |
+ }; |
|
| 166 |
+ |
|
| 167 |
+ for (key, flag) in [ |
|
| 168 |
+ ("get-task-allow", CS_EXECSEG_ALLOW_UNSIGNED),
|
|
| 169 |
+ ("run-unsigned-code", CS_EXECSEG_ALLOW_UNSIGNED),
|
|
| 170 |
+ ("com.apple.private.cs.debugger", CS_EXECSEG_DEBUGGER),
|
|
| 171 |
+ ("dynamic-codesigning", CS_EXECSEG_JIT),
|
|
| 172 |
+ ( |
|
| 173 |
+ "com.apple.private.skip-library-validation", |
|
| 174 |
+ CS_EXECSEG_SKIP_LV, |
|
| 175 |
+ ), |
|
| 176 |
+ ( |
|
| 177 |
+ "com.apple.private.amfi.can-load-cdhash", |
|
| 178 |
+ CS_EXECSEG_CAN_LOAD_CDHASH, |
|
| 179 |
+ ), |
|
| 180 |
+ ( |
|
| 181 |
+ "com.apple.private.amfi.can-execute-cdhash", |
|
| 182 |
+ CS_EXECSEG_CAN_EXEC_CDHASH, |
|
| 183 |
+ ), |
|
| 184 |
+ ] {
|
|
| 185 |
+ if matches!(entitlements.get(key), Some(Value::Boolean(true))) {
|
|
| 186 |
+ flags |= flag; |
|
| 187 |
+ } |
|
| 188 |
+ } |
|
| 189 |
+ |
|
| 190 |
+ flags |
|
| 191 |
+} |
|
| 192 |
+ |
|
| 193 |
+pub(crate) struct SuperblobSizePlan<'a> {
|
|
| 194 |
+ pub identifier: &'a str, |
|
| 195 |
+ pub team_id: &'a str, |
|
| 196 |
+ pub code_limit: usize, |
|
| 197 |
+ pub is_executable: bool, |
|
| 198 |
+ pub requirements_blob_len: usize, |
|
| 199 |
+ pub entitlements_blob_len: usize, |
|
| 200 |
+ pub der_entitlements_blob_len: Option<usize>, |
|
| 201 |
+ pub has_cms_signature: bool, |
|
| 202 |
+ pub cms_blob_reservation: usize, |
|
| 203 |
+} |
|
| 204 |
+ |
|
| 205 |
+pub(crate) fn planned_superblob_len_from_parts(plan: SuperblobSizePlan<'_>) -> usize {
|
|
| 206 |
+ let count = 4 |
|
| 207 |
+ + usize::from(plan.der_entitlements_blob_len.is_some()) |
|
| 208 |
+ + usize::from(plan.has_cms_signature); |
|
| 209 |
+ let mut len = 12 + count * 8; |
|
| 210 |
+ |
|
| 211 |
+ len += code_directory_len( |
|
| 212 |
+ HashAlgorithm::Sha1, |
|
| 213 |
+ plan.identifier, |
|
| 214 |
+ plan.team_id, |
|
| 215 |
+ plan.code_limit, |
|
| 216 |
+ plan.is_executable, |
|
| 217 |
+ ); |
|
| 218 |
+ len += code_directory_len( |
|
| 219 |
+ HashAlgorithm::Sha256, |
|
| 220 |
+ plan.identifier, |
|
| 221 |
+ plan.team_id, |
|
| 222 |
+ plan.code_limit, |
|
| 223 |
+ plan.is_executable, |
|
| 224 |
+ ); |
|
| 225 |
+ len += plan.requirements_blob_len; |
|
| 226 |
+ len += plan.entitlements_blob_len; |
|
| 227 |
+ |
|
| 228 |
+ if let Some(der_entitlements_blob_len) = plan.der_entitlements_blob_len {
|
|
| 229 |
+ len += der_entitlements_blob_len; |
|
| 230 |
+ } |
|
| 231 |
+ |
|
| 232 |
+ if plan.has_cms_signature {
|
|
| 233 |
+ len += plan.cms_blob_reservation; |
|
| 234 |
+ } |
|
| 235 |
+ |
|
| 236 |
+ len |
|
| 237 |
+} |
|
| 238 |
+ |
|
| 239 |
+pub fn encode_embedded_signature( |
|
| 240 |
+ params: &CodeDirectoryParams<'_>, |
|
| 241 |
+ cms_signer: Option<&dyn CmsSigner>, |
|
| 242 |
+ cms_blob_reservation: usize, |
|
| 243 |
+) -> Result<Vec<u8>> {
|
|
| 244 |
+ if params.code_limit > params.macho_data.len() {
|
|
| 245 |
+ return Err(CodeSignError::macho( |
|
| 246 |
+ "<memory>", |
|
| 247 |
+ format!( |
|
| 248 |
+ "code limit {} exceeds Mach-O length {}",
|
|
| 249 |
+ params.code_limit, |
|
| 250 |
+ params.macho_data.len() |
|
| 251 |
+ ), |
|
| 252 |
+ )); |
|
| 253 |
+ } |
|
| 254 |
+ |
|
| 255 |
+ let code_hashes = hash_code_pages(¶ms.macho_data[..params.code_limit]); |
|
| 256 |
+ let (primary_code_directory, alternate_code_directory) = |
|
| 257 |
+ if should_parallelize_code_directory_encoding(params, code_hashes.len()) {
|
|
| 258 |
+ let (primary, alternate) = rayon::join( |
|
| 259 |
+ || encode_code_directory(params, HashAlgorithm::Sha1, &code_hashes), |
|
| 260 |
+ || encode_code_directory(params, HashAlgorithm::Sha256, &code_hashes), |
|
| 261 |
+ ); |
|
| 262 |
+ (primary?, alternate?) |
|
| 263 |
+ } else {
|
|
| 264 |
+ ( |
|
| 265 |
+ encode_code_directory(params, HashAlgorithm::Sha1, &code_hashes)?, |
|
| 266 |
+ encode_code_directory(params, HashAlgorithm::Sha256, &code_hashes)?, |
|
| 267 |
+ ) |
|
| 268 |
+ }; |
|
| 269 |
+ |
|
| 270 |
+ let signature_blob = if let Some(cms_signer) = cms_signer {
|
|
| 271 |
+ let directories = [ |
|
| 272 |
+ EncodedCodeDirectory {
|
|
| 273 |
+ algorithm: HashAlgorithm::Sha1, |
|
| 274 |
+ bytes: &primary_code_directory, |
|
| 275 |
+ }, |
|
| 276 |
+ EncodedCodeDirectory {
|
|
| 277 |
+ algorithm: HashAlgorithm::Sha256, |
|
| 278 |
+ bytes: &alternate_code_directory, |
|
| 279 |
+ }, |
|
| 280 |
+ ]; |
|
| 281 |
+ let cms = cms_signer.sign(CmsSigningRequest {
|
|
| 282 |
+ code_directories: &directories, |
|
| 283 |
+ })?; |
|
| 284 |
+ Some(signature_blob_wrapper(&cms, cms_blob_reservation)?) |
|
| 285 |
+ } else {
|
|
| 286 |
+ None |
|
| 287 |
+ }; |
|
| 288 |
+ |
|
| 289 |
+ let mut blobs: Vec<(u32, Cow<'_, [u8]>)> = vec![ |
|
| 290 |
+ (CSSLOT_CODEDIRECTORY, Cow::Owned(primary_code_directory)), |
|
| 291 |
+ (CSSLOT_REQUIREMENTS, Cow::Borrowed(params.requirements_blob)), |
|
| 292 |
+ (CSSLOT_ENTITLEMENTS, Cow::Borrowed(params.entitlements_blob)), |
|
| 293 |
+ ]; |
|
| 294 |
+ |
|
| 295 |
+ if let Some(der_entitlements_blob) = params.der_entitlements_blob {
|
|
| 296 |
+ blobs.push(( |
|
| 297 |
+ CSSLOT_DER_ENTITLEMENTS, |
|
| 298 |
+ Cow::Borrowed(der_entitlements_blob), |
|
| 299 |
+ )); |
|
| 300 |
+ } |
|
| 301 |
+ |
|
| 302 |
+ blobs.push(( |
|
| 303 |
+ CSSLOT_ALTERNATE_CODEDIRECTORIES, |
|
| 304 |
+ Cow::Owned(alternate_code_directory), |
|
| 305 |
+ )); |
|
| 306 |
+ |
|
| 307 |
+ if let Some(signature_blob) = signature_blob {
|
|
| 308 |
+ blobs.push((CSSLOT_SIGNATURESLOT, Cow::Owned(signature_blob))); |
|
| 309 |
+ } |
|
| 310 |
+ |
|
| 311 |
+ Ok(encode_superblob(blobs)) |
|
| 312 |
+} |
|
| 313 |
+ |
|
| 314 |
+fn code_directory_len( |
|
| 315 |
+ algorithm: HashAlgorithm, |
|
| 316 |
+ identifier: &str, |
|
| 317 |
+ team_id: &str, |
|
| 318 |
+ code_limit: usize, |
|
| 319 |
+ is_executable: bool, |
|
| 320 |
+) -> usize {
|
|
| 321 |
+ let hash_len = algorithm.digest_len(); |
|
| 322 |
+ let special_slots = if is_executable { 7 } else { 5 };
|
|
| 323 |
+ let code_slots = code_limit.div_ceil(CODEDIRECTORY_PAGE_SIZE); |
|
| 324 |
+ |
|
| 325 |
+ CODEDIRECTORY_HEADER_LEN |
|
| 326 |
+ + identifier.len() |
|
| 327 |
+ + 1 |
|
| 328 |
+ + team_id.len() |
|
| 329 |
+ + 1 |
|
| 330 |
+ + (special_slots + code_slots) * hash_len |
|
| 331 |
+} |
|
| 332 |
+ |
|
| 333 |
+fn encode_code_directory( |
|
| 334 |
+ params: &CodeDirectoryParams<'_>, |
|
| 335 |
+ algorithm: HashAlgorithm, |
|
| 336 |
+ code_hashes: &[CodePageHashes], |
|
| 337 |
+) -> Result<Vec<u8>> {
|
|
| 338 |
+ let hash_len = algorithm.digest_len(); |
|
| 339 |
+ let special_slot_count = if params.is_executable { 7 } else { 5 };
|
|
| 340 |
+ let code_slot_count = code_hashes.len(); |
|
| 341 |
+ let dynamic_len = params.identifier.len() |
|
| 342 |
+ + 1 |
|
| 343 |
+ + params.team_id.len() |
|
| 344 |
+ + 1 |
|
| 345 |
+ + special_slot_count * hash_len |
|
| 346 |
+ + code_slot_count * hash_len; |
|
| 347 |
+ let ident_offset = CODEDIRECTORY_HEADER_LEN; |
|
| 348 |
+ let team_offset = ident_offset + params.identifier.len() + 1; |
|
| 349 |
+ let hash_offset = team_offset + params.team_id.len() + 1 + special_slot_count * hash_len; |
|
| 350 |
+ let length = CODEDIRECTORY_HEADER_LEN + dynamic_len; |
|
| 351 |
+ |
|
| 352 |
+ let mut out = Vec::with_capacity(length); |
|
| 353 |
+ push_be_u32(&mut out, CSMAGIC_CODEDIRECTORY); |
|
| 354 |
+ push_be_u32(&mut out, length as u32); |
|
| 355 |
+ push_be_u32(&mut out, CODEDIRECTORY_VERSION_EXECSEG); |
|
| 356 |
+ push_be_u32(&mut out, 0); |
|
| 357 |
+ push_be_u32(&mut out, hash_offset as u32); |
|
| 358 |
+ push_be_u32(&mut out, ident_offset as u32); |
|
| 359 |
+ push_be_u32(&mut out, special_slot_count as u32); |
|
| 360 |
+ push_be_u32(&mut out, code_slot_count as u32); |
|
| 361 |
+ push_be_u32(&mut out, u32::try_from(params.code_limit).unwrap_or(0)); |
|
| 362 |
+ out.push(hash_len as u8); |
|
| 363 |
+ out.push(algorithm.code_directory_hash_type()); |
|
| 364 |
+ out.push(0); |
|
| 365 |
+ out.push(CODEDIRECTORY_PAGE_SIZE_LOG2); |
|
| 366 |
+ push_be_u32(&mut out, 0); |
|
| 367 |
+ push_be_u32(&mut out, 0); |
|
| 368 |
+ push_be_u32(&mut out, team_offset as u32); |
|
| 369 |
+ push_be_u32(&mut out, 0); |
|
| 370 |
+ push_be_u64( |
|
| 371 |
+ &mut out, |
|
| 372 |
+ if params.code_limit > u32::MAX as usize {
|
|
| 373 |
+ params.code_limit as u64 |
|
| 374 |
+ } else {
|
|
| 375 |
+ 0 |
|
| 376 |
+ }, |
|
| 377 |
+ ); |
|
| 378 |
+ push_be_u64(&mut out, params.executable_segment.base); |
|
| 379 |
+ push_be_u64(&mut out, params.executable_segment.limit); |
|
| 380 |
+ push_be_u64(&mut out, params.executable_segment.flags); |
|
| 381 |
+ |
|
| 382 |
+ out.extend_from_slice(params.identifier.as_bytes()); |
|
| 383 |
+ out.push(0); |
|
| 384 |
+ out.extend_from_slice(params.team_id.as_bytes()); |
|
| 385 |
+ out.push(0); |
|
| 386 |
+ |
|
| 387 |
+ if params.is_executable {
|
|
| 388 |
+ let der = params.der_entitlements_blob.ok_or_else(|| {
|
|
| 389 |
+ CodeSignError::macho("<memory>", "executable signatures require DER entitlements")
|
|
| 390 |
+ })?; |
|
| 391 |
+ algorithm.push_digest(&mut out, der); |
|
| 392 |
+ push_zero_hash(&mut out, hash_len); |
|
| 393 |
+ } |
|
| 394 |
+ |
|
| 395 |
+ algorithm.push_digest(&mut out, params.entitlements_blob); |
|
| 396 |
+ push_zero_hash(&mut out, hash_len); |
|
| 397 |
+ |
|
| 398 |
+ if let Some(code_resources) = params.code_resources {
|
|
| 399 |
+ algorithm.push_digest(&mut out, code_resources); |
|
| 400 |
+ } else {
|
|
| 401 |
+ push_zero_hash(&mut out, hash_len); |
|
| 402 |
+ } |
|
| 403 |
+ |
|
| 404 |
+ algorithm.push_digest(&mut out, params.requirements_blob); |
|
| 405 |
+ |
|
| 406 |
+ if let Some(info_plist) = params.info_plist {
|
|
| 407 |
+ algorithm.push_digest(&mut out, info_plist); |
|
| 408 |
+ } else {
|
|
| 409 |
+ push_zero_hash(&mut out, hash_len); |
|
| 410 |
+ } |
|
| 411 |
+ |
|
| 412 |
+ for slot in code_hashes {
|
|
| 413 |
+ out.extend_from_slice(slot.get(algorithm)); |
|
| 414 |
+ } |
|
| 415 |
+ |
|
| 416 |
+ debug_assert_eq!(out.len(), length); |
|
| 417 |
+ |
|
| 418 |
+ Ok(out) |
|
| 419 |
+} |
|
| 420 |
+ |
|
| 421 |
+fn hash_code_pages(code: &[u8]) -> Vec<CodePageHashes> {
|
|
| 422 |
+ let pages = code.len().div_ceil(CODEDIRECTORY_PAGE_SIZE); |
|
| 423 |
+ let mut hashes = vec![CodePageHashes::default(); pages]; |
|
| 424 |
+ |
|
| 425 |
+ if pages < 8 {
|
|
| 426 |
+ for (hash, page) in hashes.iter_mut().zip(code.chunks(CODEDIRECTORY_PAGE_SIZE)) {
|
|
| 427 |
+ *hash = hash_code_page(page); |
|
| 428 |
+ } |
|
| 429 |
+ return hashes; |
|
| 430 |
+ } |
|
| 431 |
+ |
|
| 432 |
+ hashes |
|
| 433 |
+ .par_chunks_mut(CODEDIRECTORY_PARALLEL_HASH_PAGES) |
|
| 434 |
+ .enumerate() |
|
| 435 |
+ .for_each(|(chunk_index, out)| {
|
|
| 436 |
+ let start_page = chunk_index * CODEDIRECTORY_PARALLEL_HASH_PAGES; |
|
| 437 |
+ let start = start_page * CODEDIRECTORY_PAGE_SIZE; |
|
| 438 |
+ let end = (start + out.len() * CODEDIRECTORY_PAGE_SIZE).min(code.len()); |
|
| 439 |
+ |
|
| 440 |
+ for (hash, page) in out |
|
| 441 |
+ .iter_mut() |
|
| 442 |
+ .zip(code[start..end].chunks(CODEDIRECTORY_PAGE_SIZE)) |
|
| 443 |
+ {
|
|
| 444 |
+ *hash = hash_code_page(page); |
|
| 445 |
+ } |
|
| 446 |
+ }); |
|
| 447 |
+ |
|
| 448 |
+ hashes |
|
| 449 |
+} |
|
| 450 |
+ |
|
| 451 |
+fn hash_code_page(page: &[u8]) -> CodePageHashes {
|
|
| 452 |
+ CodePageHashes {
|
|
| 453 |
+ sha1: sha1::Sha1::digest(page).into(), |
|
| 454 |
+ sha256: sha2::Sha256::digest(page).into(), |
|
| 455 |
+ } |
|
| 456 |
+} |
|
| 457 |
+ |
|
| 458 |
+fn should_parallelize_code_directory_encoding( |
|
| 459 |
+ params: &CodeDirectoryParams<'_>, |
|
| 460 |
+ code_page_count: usize, |
|
| 461 |
+) -> bool {
|
|
| 462 |
+ code_page_count >= 8 |
|
| 463 |
+ || params |
|
| 464 |
+ .code_resources |
|
| 465 |
+ .is_some_and(|data| data.len() >= 64 * 1024) |
|
| 466 |
+} |
|
| 467 |
+ |
|
| 468 |
+fn push_zero_hash(out: &mut Vec<u8>, hash_len: usize) {
|
|
| 469 |
+ out.resize(out.len() + hash_len, 0); |
|
| 470 |
+} |
|
| 471 |
+ |
|
| 472 |
+fn signature_blob_wrapper(cms: &[u8], reserved_len: usize) -> Result<Vec<u8>> {
|
|
| 473 |
+ let actual_len = 8 + cms.len(); |
|
| 474 |
+ if actual_len > reserved_len {
|
|
| 475 |
+ return Err(CodeSignError::SignatureTooLarge {
|
|
| 476 |
+ actual: actual_len, |
|
| 477 |
+ reserved: reserved_len, |
|
| 478 |
+ }); |
|
| 479 |
+ } |
|
| 480 |
+ |
|
| 481 |
+ let mut out = Vec::with_capacity(actual_len); |
|
| 482 |
+ push_be_u32(&mut out, CSMAGIC_BLOBWRAPPER); |
|
| 483 |
+ push_be_u32(&mut out, actual_len as u32); |
|
| 484 |
+ out.extend_from_slice(cms); |
|
| 485 |
+ Ok(out) |
|
| 486 |
+} |
|
| 487 |
+ |
|
| 488 |
+fn encode_superblob(mut blobs: Vec<(u32, Cow<'_, [u8]>)>) -> Vec<u8> {
|
|
| 489 |
+ if let Some(primary_index) = blobs |
|
| 490 |
+ .iter() |
|
| 491 |
+ .position(|(slot, _)| *slot == CSSLOT_CODEDIRECTORY) |
|
| 492 |
+ {
|
|
| 493 |
+ blobs.swap(0, primary_index); |
|
| 494 |
+ } |
|
| 495 |
+ |
|
| 496 |
+ let index_len = blobs.len() * 8; |
|
| 497 |
+ let data_len: usize = blobs.iter().map(|(_, data)| data.as_ref().len()).sum(); |
|
| 498 |
+ let total_len = 12 + index_len + data_len; |
|
| 499 |
+ |
|
| 500 |
+ let mut out = Vec::with_capacity(total_len); |
|
| 501 |
+ push_be_u32(&mut out, CSMAGIC_EMBEDDED_SIGNATURE); |
|
| 502 |
+ push_be_u32(&mut out, total_len as u32); |
|
| 503 |
+ push_be_u32(&mut out, blobs.len() as u32); |
|
| 504 |
+ |
|
| 505 |
+ let mut offset = 12 + index_len; |
|
| 506 |
+ for (slot, data) in &blobs {
|
|
| 507 |
+ push_be_u32(&mut out, *slot); |
|
| 508 |
+ push_be_u32(&mut out, offset as u32); |
|
| 509 |
+ offset += data.as_ref().len(); |
|
| 510 |
+ } |
|
| 511 |
+ |
|
| 512 |
+ for (_, data) in blobs {
|
|
| 513 |
+ out.extend_from_slice(data.as_ref()); |
|
| 514 |
+ } |
|
| 515 |
+ |
|
| 516 |
+ out |
|
| 517 |
+} |
|
| 518 |
+ |
|
| 519 |
+pub fn der_entitlements(entitlements: &Dictionary) -> Result<Vec<u8>> {
|
|
| 520 |
+ let dictionary = der_dictionary_body(entitlements, "$")?; |
|
| 521 |
+ let mut body = der_tlv(0x02, &[1]); |
|
| 522 |
+ body.extend_from_slice(&der_tlv(0xb0, &dictionary)); |
|
| 523 |
+ Ok(der_tlv(0x70, &body)) |
|
| 524 |
+} |
|
| 525 |
+ |
|
| 526 |
+fn der_value(value: &Value, path: &str) -> Result<Vec<u8>> {
|
|
| 527 |
+ match value {
|
|
| 528 |
+ Value::Boolean(value) => Ok(der_tlv(0x01, &[if *value { 0xff } else { 0x00 }])),
|
|
| 529 |
+ Value::Integer(value) => {
|
|
| 530 |
+ if let Some(unsigned) = value.as_unsigned() {
|
|
| 531 |
+ Ok(der_unsigned_integer(unsigned)) |
|
| 532 |
+ } else if let Some(signed) = value.as_signed() {
|
|
| 533 |
+ if signed >= 0 {
|
|
| 534 |
+ Ok(der_unsigned_integer(signed as u64)) |
|
| 535 |
+ } else {
|
|
| 536 |
+ Err(CodeSignError::UnsupportedEntitlement(path.to_string())) |
|
| 537 |
+ } |
|
| 538 |
+ } else {
|
|
| 539 |
+ Err(CodeSignError::UnsupportedEntitlement(path.to_string())) |
|
| 540 |
+ } |
|
| 541 |
+ } |
|
| 542 |
+ Value::String(value) => Ok(der_tlv(0x0c, value.as_bytes())), |
|
| 543 |
+ Value::Array(values) => {
|
|
| 544 |
+ let mut body = Vec::new(); |
|
| 545 |
+ for (index, value) in values.iter().enumerate() {
|
|
| 546 |
+ body.extend_from_slice(&der_value(value, &format!("{path}[{index}]"))?);
|
|
| 547 |
+ } |
|
| 548 |
+ Ok(der_tlv(0x30, &body)) |
|
| 549 |
+ } |
|
| 550 |
+ Value::Dictionary(value) => der_dictionary(value, path), |
|
| 551 |
+ _ => Err(CodeSignError::UnsupportedEntitlement(path.to_string())), |
|
| 552 |
+ } |
|
| 553 |
+} |
|
| 554 |
+ |
|
| 555 |
+fn der_dictionary(dict: &Dictionary, path: &str) -> Result<Vec<u8>> {
|
|
| 556 |
+ Ok(der_tlv(0x31, &der_dictionary_body(dict, path)?)) |
|
| 557 |
+} |
|
| 558 |
+ |
|
| 559 |
+fn der_dictionary_body(dict: &Dictionary, path: &str) -> Result<Vec<u8>> {
|
|
| 560 |
+ let mut entries = Vec::with_capacity(dict.len()); |
|
| 561 |
+ |
|
| 562 |
+ for (key, value) in dict {
|
|
| 563 |
+ let mut entry_body = der_tlv(0x0c, key.as_bytes()); |
|
| 564 |
+ entry_body.extend_from_slice(&der_value(value, &format!("{path}.{key}"))?);
|
|
| 565 |
+ entries.push((key.as_str(), der_tlv(0x30, &entry_body))); |
|
| 566 |
+ } |
|
| 567 |
+ |
|
| 568 |
+ entries.sort_by(|(left, _), (right, _)| left.as_bytes().cmp(right.as_bytes())); |
|
| 569 |
+ |
|
| 570 |
+ let body_len = entries.iter().map(|(_, entry)| entry.len()).sum(); |
|
| 571 |
+ let mut body = Vec::with_capacity(body_len); |
|
| 572 |
+ for (_, entry) in entries {
|
|
| 573 |
+ body.extend_from_slice(&entry); |
|
| 574 |
+ } |
|
| 575 |
+ |
|
| 576 |
+ Ok(body) |
|
| 577 |
+} |
|
| 578 |
+ |
|
| 579 |
+fn der_unsigned_integer(value: u64) -> Vec<u8> {
|
|
| 580 |
+ let bytes = value.to_be_bytes(); |
|
| 581 |
+ let first_non_zero = bytes |
|
| 582 |
+ .iter() |
|
| 583 |
+ .position(|byte| *byte != 0) |
|
| 584 |
+ .unwrap_or(bytes.len() - 1); |
|
| 585 |
+ let significant = &bytes[first_non_zero..]; |
|
| 586 |
+ |
|
| 587 |
+ let mut body = Vec::with_capacity(significant.len() + 1); |
|
| 588 |
+ if significant[0] & 0x80 != 0 {
|
|
| 589 |
+ body.push(0); |
|
| 590 |
+ } |
|
| 591 |
+ body.extend_from_slice(significant); |
|
| 592 |
+ der_tlv(0x02, &body) |
|
| 593 |
+} |
|
| 594 |
+ |
|
| 595 |
+fn der_tlv(tag: u8, body: &[u8]) -> Vec<u8> {
|
|
| 596 |
+ let mut out = Vec::with_capacity(1 + 5 + body.len()); |
|
| 597 |
+ out.push(tag); |
|
| 598 |
+ write_der_len(&mut out, body.len()); |
|
| 599 |
+ out.extend_from_slice(body); |
|
| 600 |
+ out |
|
| 601 |
+} |
|
| 602 |
+ |
|
| 603 |
+fn write_der_len(out: &mut Vec<u8>, len: usize) {
|
|
| 604 |
+ if len < 128 {
|
|
| 605 |
+ out.push(len as u8); |
|
| 606 |
+ return; |
|
| 607 |
+ } |
|
| 608 |
+ |
|
| 609 |
+ let bytes = len.to_be_bytes(); |
|
| 610 |
+ let first = bytes |
|
| 611 |
+ .iter() |
|
| 612 |
+ .position(|byte| *byte != 0) |
|
| 613 |
+ .unwrap_or(bytes.len() - 1); |
|
| 614 |
+ let significant = &bytes[first..]; |
|
| 615 |
+ out.push(0x80 | significant.len() as u8); |
|
| 616 |
+ out.extend_from_slice(significant); |
|
| 617 |
+} |
|
| 618 |
+ |
|
| 619 |
+fn push_be_u32(out: &mut Vec<u8>, value: u32) {
|
|
| 620 |
+ out.extend_from_slice(&value.to_be_bytes()); |
|
| 621 |
+} |
|
| 622 |
+ |
|
| 623 |
+fn push_be_u64(out: &mut Vec<u8>, value: u64) {
|
|
| 624 |
+ out.extend_from_slice(&value.to_be_bytes()); |
|
| 625 |
+} |
|
| 626 |
+ |
|
| 627 |
+#[cfg(test)] |
|
| 628 |
+mod tests {
|
|
| 629 |
+ use super::*; |
|
| 630 |
+ |
|
| 631 |
+ #[test] |
|
| 632 |
+ fn der_entitlements_use_apple_versioned_application_envelope() {
|
|
| 633 |
+ assert_eq!( |
|
| 634 |
+ der_entitlements(&Dictionary::new()).unwrap(), |
|
| 635 |
+ [0x70, 0x05, 0x02, 0x01, 0x01, 0xb0, 0x00] |
|
| 636 |
+ ); |
|
| 637 |
+ } |
|
| 638 |
+ |
|
| 639 |
+ #[test] |
|
| 640 |
+ fn der_entitlements_blob_reports_the_wrapped_length() {
|
|
| 641 |
+ let blob = der_entitlements_blob(&Dictionary::new()).unwrap(); |
|
| 642 |
+ |
|
| 643 |
+ assert_eq!(&blob[..4], &CSMAGIC_EMBEDDED_DER_ENTITLEMENTS.to_be_bytes()); |
|
| 644 |
+ assert_eq!( |
|
| 645 |
+ u32::from_be_bytes(blob[4..8].try_into().unwrap()) as usize, |
|
| 646 |
+ blob.len() |
|
| 647 |
+ ); |
|
| 648 |
+ assert_eq!(&blob[8..], &[0x70, 0x05, 0x02, 0x01, 0x01, 0xb0, 0x00]); |
|
| 649 |
+ } |
|
| 650 |
+ |
|
| 651 |
+ #[test] |
|
| 652 |
+ fn entitlement_dictionary_entries_are_sorted_by_key() {
|
|
| 653 |
+ let mut entitlements = Dictionary::new(); |
|
| 654 |
+ entitlements.insert("z".to_string(), Value::Boolean(true));
|
|
| 655 |
+ entitlements.insert( |
|
| 656 |
+ "application-identifier".to_string(), |
|
| 657 |
+ Value::String("TEAM.com.example.App".to_string()),
|
|
| 658 |
+ ); |
|
| 659 |
+ let body = der_dictionary_body(&entitlements, "$").unwrap(); |
|
| 660 |
+ let expected_first = der_tlv( |
|
| 661 |
+ 0x30, |
|
| 662 |
+ &[ |
|
| 663 |
+ der_tlv(0x0c, b"application-identifier"), |
|
| 664 |
+ der_tlv(0x0c, b"TEAM.com.example.App"), |
|
| 665 |
+ ] |
|
| 666 |
+ .concat(), |
|
| 667 |
+ ); |
|
| 668 |
+ |
|
| 669 |
+ assert!(body.starts_with(&expected_first)); |
|
| 670 |
+ } |
|
| 671 |
+ |
|
| 672 |
+ #[test] |
|
| 673 |
+ fn cms_reservation_is_not_encoded_inside_blob_wrapper() {
|
|
| 674 |
+ let cms = [0x30, 0x00]; |
|
| 675 |
+ let blob = signature_blob_wrapper(&cms, 32).unwrap(); |
|
| 676 |
+ |
|
| 677 |
+ assert_eq!(blob.len(), 10); |
|
| 678 |
+ assert_eq!(u32::from_be_bytes(blob[4..8].try_into().unwrap()), 10); |
|
| 679 |
+ assert_eq!(&blob[8..10], &cms); |
|
| 680 |
+ } |
|
| 681 |
+ |
|
| 682 |
+ #[test] |
|
| 683 |
+ fn cms_reservation_is_not_counted_in_outer_superblob_length() {
|
|
| 684 |
+ let cms = signature_blob_wrapper(&[0x30, 0x00], 32).unwrap(); |
|
| 685 |
+ let superblob = encode_superblob(vec![ |
|
| 686 |
+ (CSSLOT_CODEDIRECTORY, Cow::Owned(vec![0; 8])), |
|
| 687 |
+ (CSSLOT_SIGNATURESLOT, Cow::Owned(cms)), |
|
| 688 |
+ ]); |
|
| 689 |
+ |
|
| 690 |
+ assert_eq!( |
|
| 691 |
+ u32::from_be_bytes(superblob[4..8].try_into().unwrap()) as usize, |
|
| 692 |
+ superblob.len() |
|
| 693 |
+ ); |
|
| 694 |
+ assert_eq!(superblob.len(), 46); |
|
| 695 |
+ } |
|
| 696 |
+} |
| ... | ... |
@@ -6,28 +6,35 @@ edition = "2021" |
| 6 | 6 |
[dependencies] |
| 7 | 7 |
adi.workspace = true |
| 8 | 8 |
android-coreadi.workspace = true |
| 9 |
-async_zip = { version = "0.0.18", features = ["deflate", "tokio-fs"] }
|
|
| 9 |
+apple-codesign = { path = "../apple-codesign-quick" }
|
|
| 10 |
+async_zip = { version = "0.0.18", features = ["deflate", "tokio", "tokio-fs"] }
|
|
| 10 | 11 |
chrono = { version = "0.4.42", default-features = false, features = ["clock"] }
|
| 12 |
+crc32fast = "1.5" |
|
| 11 | 13 |
directories = "6.0.0" |
| 12 | 14 |
env_logger = "0.11.8" |
| 15 |
+flate2 = "1.1" |
|
| 13 | 16 |
futures-lite = "2.6.1" |
| 14 | 17 |
grandslam.workspace = true |
| 15 | 18 |
gpui = { package = "gpui", git = "https://github.com/gpui-ce/gpui-ce" }
|
| 16 | 19 |
gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "277f220a6196572116a8fd84fba6c36187094cab" }
|
| 17 | 20 |
gpui_platform = { git = "https://github.com/gpui-ce/gpui-ce", features = ["font-kit", "x11", "wayland", "runtime_shaders"] }
|
| 18 |
-idevice = { version = "0.1.64", features = ["usbmuxd"] }
|
|
| 21 |
+idevice = { version = "0.1.64", features = ["afc", "installation_proxy", "usbmuxd"] }
|
|
| 22 |
+image = { version = "0.25", default-features = false, features = ["png"] }
|
|
| 19 | 23 |
futures = "0.3" |
| 20 | 24 |
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
|
| 21 | 25 |
log = "0.4.33" |
| 22 | 26 |
plist.workspace = true |
| 27 |
+pem-rfc7468 = { version = "0.7", features = ["alloc"] }
|
|
| 23 | 28 |
rand = "0.10.2" |
| 24 | 29 |
reqwest = { workspace = true, features = ["blocking"] }
|
| 25 | 30 |
serde = { version = "1.0.228", features = ["derive"] }
|
| 26 | 31 |
sha1.workspace = true |
| 27 | 32 |
thiserror.workspace = true |
| 33 |
+tempfile = "3.27" |
|
| 28 | 34 |
toml = "0.8.23" |
| 29 |
-tokio = { workspace = true, features = ["io-util", "net", "time"] }
|
|
| 35 |
+tokio = { workspace = true, features = ["fs", "io-util", "net", "time"] }
|
|
| 30 | 36 |
uuid = { version = "1.19", features = ["v4"] }
|
| 37 |
+walkdir = "2.5" |
|
| 31 | 38 |
xcode.workspace = true |
| 32 | 39 |
|
| 33 | 40 |
[target.'cfg(target_os = "macos")'.dependencies] |
| ... | ... |
@@ -1,14 +1,20 @@ |
| 1 |
+use crate::app::entitlements::{
|
|
| 2 |
+ effective_bundle_identifier, effective_bundle_identifier_for_app, |
|
| 3 |
+ effective_nested_bundle_identifier, |
|
| 4 |
+}; |
|
| 1 | 5 |
use crate::app::models::{
|
| 2 |
- AccountOption, AdiBackendKind, AdiBackendOption, AppOption, DeviceOption, MachineIdentity, |
|
| 3 |
- PatchOption, |
|
| 6 |
+ AccountOption, AdiBackendKind, AdiBackendOption, AppIdOption, AppOption, DeveloperDeviceOption, |
|
| 7 |
+ DeviceOption, MachineIdentity, PatchOption, TeamOption, |
|
| 4 | 8 |
}; |
| 5 | 9 |
use crate::app::selection; |
| 6 | 10 |
use crate::app::view_models::{
|
| 7 |
- account_option, account_options, adi_backends, app_option, device_options, domain_adi_kind, |
|
| 8 |
- domain_machine_identity, domain_patch, machine_identity, |
|
| 11 |
+ account_option, account_options, adi_backends, app_option, developer_device_options, |
|
| 12 |
+ device_options, domain_adi_kind, domain_app_metadata, domain_machine_identity, domain_patch, |
|
| 13 |
+ machine_identity, |
|
| 9 | 14 |
}; |
| 10 | 15 |
use crate::app::{AppError, AppResult};
|
| 11 | 16 |
use crate::backend::{adi_services, developer_services, device_discovery, system_identity};
|
| 17 |
+use std::collections::BTreeMap; |
|
| 12 | 18 |
use std::path::{Path, PathBuf};
|
| 13 | 19 |
|
| 14 | 20 |
pub(crate) use crate::domain::DeviceWatchEvent; |
| ... | ... |
@@ -17,6 +23,220 @@ pub(crate) use developer_services::{
|
| 17 | 23 |
DeveloperAppIdCapabilityUpdate, DownloadedProvisioningProfile, |
| 18 | 24 |
}; |
| 19 | 25 |
|
| 26 |
+#[derive(Clone)] |
|
| 27 |
+pub(crate) struct SignIpaRequest {
|
|
| 28 |
+ pub(crate) developer_context: DeveloperSessionContext, |
|
| 29 |
+ pub(crate) team_id: String, |
|
| 30 |
+ pub(crate) team_app_ids: Vec<AppIdOption>, |
|
| 31 |
+ pub(crate) certificate_fingerprint: String, |
|
| 32 |
+ pub(crate) public_key_fingerprint: String, |
|
| 33 |
+ pub(crate) auto_app_id: bool, |
|
| 34 |
+ pub(crate) selected_app_id: Option<AppIdOption>, |
|
| 35 |
+ pub(crate) app: AppOption, |
|
| 36 |
+ pub(crate) output: SignIpaOutput, |
|
| 37 |
+} |
|
| 38 |
+ |
|
| 39 |
+pub(crate) struct SignIpaOutcome {
|
|
| 40 |
+ pub(crate) artifact: SignIpaArtifact, |
|
| 41 |
+ pub(crate) updated_account: Option<AccountOption>, |
|
| 42 |
+} |
|
| 43 |
+ |
|
| 44 |
+#[derive(Clone)] |
|
| 45 |
+pub(crate) enum SignIpaOutput {
|
|
| 46 |
+ Ipa(PathBuf), |
|
| 47 |
+ AppBundle, |
|
| 48 |
+} |
|
| 49 |
+ |
|
| 50 |
+pub(crate) enum SignIpaArtifact {
|
|
| 51 |
+ Ipa(PathBuf), |
|
| 52 |
+ AppBundle(crate::backend::ipa::SignedAppBundle), |
|
| 53 |
+} |
|
| 54 |
+ |
|
| 55 |
+#[derive(Clone, Debug, Eq, PartialEq)] |
|
| 56 |
+pub(crate) struct AppIdProvisioningItem {
|
|
| 57 |
+ pub(crate) name: String, |
|
| 58 |
+ pub(crate) identifier: String, |
|
| 59 |
+} |
|
| 60 |
+ |
|
| 61 |
+#[derive(Clone, Debug, Eq, PartialEq)] |
|
| 62 |
+pub(crate) struct AppIdProvisioningPlan {
|
|
| 63 |
+ pub(crate) app_ids: Vec<AppIdProvisioningItem>, |
|
| 64 |
+ pub(crate) available_quantity: Option<u64>, |
|
| 65 |
+} |
|
| 66 |
+ |
|
| 67 |
+impl AppIdProvisioningPlan {
|
|
| 68 |
+ pub(crate) fn remaining_after_signing(&self) -> Option<u64> {
|
|
| 69 |
+ self.available_quantity |
|
| 70 |
+ .map(|available| available.saturating_sub(self.app_ids.len() as u64)) |
|
| 71 |
+ } |
|
| 72 |
+} |
|
| 73 |
+ |
|
| 74 |
+#[derive(Clone, Copy, Debug, Eq, PartialEq)] |
|
| 75 |
+pub(crate) enum SignIpaProgress {
|
|
| 76 |
+ Preparing, |
|
| 77 |
+ ResolvingAppId, |
|
| 78 |
+ DownloadingProfile, |
|
| 79 |
+ Extracting { completed: usize, total: usize },
|
|
| 80 |
+ Patching, |
|
| 81 |
+ Signing, |
|
| 82 |
+ Packaging { completed: usize, total: usize },
|
|
| 83 |
+ Saving, |
|
| 84 |
+ Ready, |
|
| 85 |
+} |
|
| 86 |
+ |
|
| 87 |
+impl SignIpaProgress {
|
|
| 88 |
+ pub(crate) fn progress(self) -> f32 {
|
|
| 89 |
+ match self {
|
|
| 90 |
+ Self::Preparing => 0.04, |
|
| 91 |
+ Self::ResolvingAppId => 0.1, |
|
| 92 |
+ Self::DownloadingProfile => 0.22, |
|
| 93 |
+ Self::Extracting { completed, total } => 0.3 + progress_ratio(completed, total) * 0.25,
|
|
| 94 |
+ Self::Patching => 0.58, |
|
| 95 |
+ Self::Signing => 0.64, |
|
| 96 |
+ Self::Packaging { completed, total } => 0.72 + progress_ratio(completed, total) * 0.24,
|
|
| 97 |
+ Self::Saving => 0.98, |
|
| 98 |
+ Self::Ready => 0.98, |
|
| 99 |
+ } |
|
| 100 |
+ } |
|
| 101 |
+ |
|
| 102 |
+ pub(crate) fn label(self) -> String {
|
|
| 103 |
+ match self {
|
|
| 104 |
+ Self::Preparing => "Preparing signing resources".to_string(), |
|
| 105 |
+ Self::ResolvingAppId => "Resolving App ID".to_string(), |
|
| 106 |
+ Self::DownloadingProfile => "Downloading provisioning profile".to_string(), |
|
| 107 |
+ Self::Extracting { completed, total } => {
|
|
| 108 |
+ format!("Extracting IPA ({completed}/{total})")
|
|
| 109 |
+ } |
|
| 110 |
+ Self::Patching => "Applying app changes".to_string(), |
|
| 111 |
+ Self::Signing => "Signing app bundle".to_string(), |
|
| 112 |
+ Self::Packaging { completed, total } => {
|
|
| 113 |
+ format!("Packaging signed IPA ({completed}/{total})")
|
|
| 114 |
+ } |
|
| 115 |
+ Self::Saving => "Saving signed IPA".to_string(), |
|
| 116 |
+ Self::Ready => "Signed app ready for transfer".to_string(), |
|
| 117 |
+ } |
|
| 118 |
+ } |
|
| 119 |
+} |
|
| 120 |
+ |
|
| 121 |
+impl From<crate::backend::ipa::IpaSigningProgress> for SignIpaProgress {
|
|
| 122 |
+ fn from(progress: crate::backend::ipa::IpaSigningProgress) -> Self {
|
|
| 123 |
+ match progress {
|
|
| 124 |
+ crate::backend::ipa::IpaSigningProgress::Extracting { completed, total } => {
|
|
| 125 |
+ Self::Extracting { completed, total }
|
|
| 126 |
+ } |
|
| 127 |
+ crate::backend::ipa::IpaSigningProgress::Patching => Self::Patching, |
|
| 128 |
+ crate::backend::ipa::IpaSigningProgress::Signing => Self::Signing, |
|
| 129 |
+ crate::backend::ipa::IpaSigningProgress::Packaging { completed, total } => {
|
|
| 130 |
+ Self::Packaging { completed, total }
|
|
| 131 |
+ } |
|
| 132 |
+ crate::backend::ipa::IpaSigningProgress::Saving => Self::Saving, |
|
| 133 |
+ crate::backend::ipa::IpaSigningProgress::Ready => Self::Ready, |
|
| 134 |
+ } |
|
| 135 |
+ } |
|
| 136 |
+} |
|
| 137 |
+ |
|
| 138 |
+#[derive(Clone, Copy, Debug, Eq, PartialEq)] |
|
| 139 |
+pub(crate) enum InstallAppProgress {
|
|
| 140 |
+ Connecting, |
|
| 141 |
+ Uploading {
|
|
| 142 |
+ transferred_bytes: u64, |
|
| 143 |
+ total_bytes: u64, |
|
| 144 |
+ completed_files: usize, |
|
| 145 |
+ total_files: usize, |
|
| 146 |
+ }, |
|
| 147 |
+ Installing {
|
|
| 148 |
+ percent: u64, |
|
| 149 |
+ }, |
|
| 150 |
+ Finalizing, |
|
| 151 |
+} |
|
| 152 |
+ |
|
| 153 |
+impl InstallAppProgress {
|
|
| 154 |
+ pub(crate) fn progress(self) -> f32 {
|
|
| 155 |
+ match self {
|
|
| 156 |
+ Self::Connecting => 0.02, |
|
| 157 |
+ Self::Uploading {
|
|
| 158 |
+ transferred_bytes, |
|
| 159 |
+ total_bytes, |
|
| 160 |
+ .. |
|
| 161 |
+ } => 0.05 + byte_progress_ratio(transferred_bytes, total_bytes) * 0.43, |
|
| 162 |
+ Self::Installing { percent } => 0.5 + (percent.min(100) as f32 / 100.) * 0.47,
|
|
| 163 |
+ Self::Finalizing => 0.99, |
|
| 164 |
+ } |
|
| 165 |
+ } |
|
| 166 |
+ |
|
| 167 |
+ pub(crate) fn label(self) -> String {
|
|
| 168 |
+ match self {
|
|
| 169 |
+ Self::Connecting => "Connecting to device".to_string(), |
|
| 170 |
+ Self::Uploading {
|
|
| 171 |
+ transferred_bytes, |
|
| 172 |
+ total_bytes, |
|
| 173 |
+ completed_files, |
|
| 174 |
+ total_files, |
|
| 175 |
+ } => format!( |
|
| 176 |
+ "Uploading app files ({completed_files}/{total_files}, {} / {})",
|
|
| 177 |
+ formatted_byte_count(transferred_bytes), |
|
| 178 |
+ formatted_byte_count(total_bytes) |
|
| 179 |
+ ), |
|
| 180 |
+ Self::Installing { percent } => {
|
|
| 181 |
+ format!("Verifying and installing on device ({percent}%)")
|
|
| 182 |
+ } |
|
| 183 |
+ Self::Finalizing => "Cleaning up device staging".to_string(), |
|
| 184 |
+ } |
|
| 185 |
+ } |
|
| 186 |
+} |
|
| 187 |
+ |
|
| 188 |
+impl From<crate::domain::DeviceInstallProgress> for InstallAppProgress {
|
|
| 189 |
+ fn from(progress: crate::domain::DeviceInstallProgress) -> Self {
|
|
| 190 |
+ match progress {
|
|
| 191 |
+ crate::domain::DeviceInstallProgress::Connecting => Self::Connecting, |
|
| 192 |
+ crate::domain::DeviceInstallProgress::Uploading {
|
|
| 193 |
+ transferred_bytes, |
|
| 194 |
+ total_bytes, |
|
| 195 |
+ completed_files, |
|
| 196 |
+ total_files, |
|
| 197 |
+ } => Self::Uploading {
|
|
| 198 |
+ transferred_bytes, |
|
| 199 |
+ total_bytes, |
|
| 200 |
+ completed_files, |
|
| 201 |
+ total_files, |
|
| 202 |
+ }, |
|
| 203 |
+ crate::domain::DeviceInstallProgress::Installing { percent } => {
|
|
| 204 |
+ Self::Installing { percent }
|
|
| 205 |
+ } |
|
| 206 |
+ crate::domain::DeviceInstallProgress::Finalizing => Self::Finalizing, |
|
| 207 |
+ } |
|
| 208 |
+ } |
|
| 209 |
+} |
|
| 210 |
+ |
|
| 211 |
+fn progress_ratio(completed: usize, total: usize) -> f32 {
|
|
| 212 |
+ if total == 0 {
|
|
| 213 |
+ 1. |
|
| 214 |
+ } else {
|
|
| 215 |
+ (completed as f32 / total as f32).clamp(0., 1.) |
|
| 216 |
+ } |
|
| 217 |
+} |
|
| 218 |
+ |
|
| 219 |
+fn byte_progress_ratio(completed: u64, total: u64) -> f32 {
|
|
| 220 |
+ if total == 0 {
|
|
| 221 |
+ 1. |
|
| 222 |
+ } else {
|
|
| 223 |
+ (completed as f32 / total as f32).clamp(0., 1.) |
|
| 224 |
+ } |
|
| 225 |
+} |
|
| 226 |
+ |
|
| 227 |
+fn formatted_byte_count(bytes: u64) -> String {
|
|
| 228 |
+ const MEBIBYTE: f64 = 1024. * 1024.; |
|
| 229 |
+ const KIBIBYTE: f64 = 1024.; |
|
| 230 |
+ |
|
| 231 |
+ if bytes as f64 >= MEBIBYTE {
|
|
| 232 |
+ format!("{:.1} MB", bytes as f64 / MEBIBYTE)
|
|
| 233 |
+ } else if bytes as f64 >= KIBIBYTE {
|
|
| 234 |
+ format!("{:.1} KB", bytes as f64 / KIBIBYTE)
|
|
| 235 |
+ } else {
|
|
| 236 |
+ format!("{bytes} B")
|
|
| 237 |
+ } |
|
| 238 |
+} |
|
| 239 |
+ |
|
| 20 | 240 |
#[derive(Clone, Debug)] |
| 21 | 241 |
pub(crate) struct DeveloperSessionContext {
|
| 22 | 242 |
inner: developer_services::DeveloperSessionContext, |
| ... | ... |
@@ -157,6 +377,315 @@ pub(crate) async fn load_ipa(path: PathBuf, patches: Vec<PatchOption>) -> AppRes |
| 157 | 377 |
.map_err(AppError::from) |
| 158 | 378 |
} |
| 159 | 379 |
|
| 380 |
+pub(crate) async fn install_app( |
|
| 381 |
+ udid: String, |
|
| 382 |
+ signed_app: crate::backend::ipa::SignedAppBundle, |
|
| 383 |
+ mut progress: impl FnMut(InstallAppProgress) + Send + 'static, |
|
| 384 |
+) -> AppResult<()> {
|
|
| 385 |
+ crate::backend::device::install_app(udid, signed_app, move |event| {
|
|
| 386 |
+ progress(event.into()); |
|
| 387 |
+ }) |
|
| 388 |
+ .await |
|
| 389 |
+ .map_err(AppError::from) |
|
| 390 |
+} |
|
| 391 |
+ |
|
| 392 |
+pub(crate) async fn sign_ipa( |
|
| 393 |
+ request: SignIpaRequest, |
|
| 394 |
+ mut progress: impl FnMut(SignIpaProgress) + Send + 'static, |
|
| 395 |
+) -> AppResult<SignIpaOutcome> {
|
|
| 396 |
+ if request.app.bundle_id().trim().is_empty() {
|
|
| 397 |
+ return Err(AppError::from( |
|
| 398 |
+ "The app bundle identifier cannot be empty when signing.", |
|
| 399 |
+ )); |
|
| 400 |
+ } |
|
| 401 |
+ if request.app.name().trim().is_empty() {
|
|
| 402 |
+ return Err(AppError::from("The app name cannot be empty when signing."));
|
|
| 403 |
+ } |
|
| 404 |
+ progress(SignIpaProgress::Preparing); |
|
| 405 |
+ let signing_material = developer_services::load_signing_material( |
|
| 406 |
+ &request.certificate_fingerprint, |
|
| 407 |
+ &request.public_key_fingerprint, |
|
| 408 |
+ ) |
|
| 409 |
+ .map_err(AppError::from)?; |
|
| 410 |
+ |
|
| 411 |
+ progress(SignIpaProgress::ResolvingAppId); |
|
| 412 |
+ let root_identifier = developer_app_identifier(&request.team_id, request.app.bundle_id()); |
|
| 413 |
+ let (app_id, mut updated_account) = resolve_signing_app_id( |
|
| 414 |
+ request.developer_context.clone(), |
|
| 415 |
+ &request.team_id, |
|
| 416 |
+ &request.team_app_ids, |
|
| 417 |
+ request.auto_app_id, |
|
| 418 |
+ request.selected_app_id, |
|
| 419 |
+ &root_identifier, |
|
| 420 |
+ request.app.name(), |
|
| 421 |
+ ) |
|
| 422 |
+ .await?; |
|
| 423 |
+ if app_id.developer_id.trim().is_empty() {
|
|
| 424 |
+ return Err(AppError::from( |
|
| 425 |
+ "The selected App ID is missing its Xcode identifier. Refresh Developer Settings, then try again.", |
|
| 426 |
+ )); |
|
| 427 |
+ } |
|
| 428 |
+ let expected_identifier = developer_app_identifier(&request.team_id, request.app.bundle_id()); |
|
| 429 |
+ if !app_id_matches_identifier(&app_id.identifier, &expected_identifier) {
|
|
| 430 |
+ return Err(AppError::from(format!( |
|
| 431 |
+ "App ID {} does not match bundle identifier {}. Select a matching App ID in Developer Settings.",
|
|
| 432 |
+ app_id.identifier, |
|
| 433 |
+ request.app.bundle_id() |
|
| 434 |
+ ))); |
|
| 435 |
+ } |
|
| 436 |
+ |
|
| 437 |
+ let mut team_app_ids = updated_account |
|
| 438 |
+ .as_ref() |
|
| 439 |
+ .and_then(|account| team_app_ids_for_account(account, &request.team_id)) |
|
| 440 |
+ .unwrap_or_else(|| request.team_app_ids.clone()); |
|
| 441 |
+ let effective_bundle_id = effective_bundle_identifier_for_app(&request.app, &request.team_id); |
|
| 442 |
+ let mut bundle_id_replacements = BTreeMap::from([( |
|
| 443 |
+ request.app.bundle_id().to_string(), |
|
| 444 |
+ effective_bundle_id.clone(), |
|
| 445 |
+ )]); |
|
| 446 |
+ let mut profile_requests = vec![(effective_bundle_id.clone(), app_id.developer_id)]; |
|
| 447 |
+ |
|
| 448 |
+ for nested_bundle in &request.app.nested_bundles {
|
|
| 449 |
+ let effective_nested_bundle_id = effective_nested_bundle_identifier( |
|
| 450 |
+ request.app.original_bundle_id(), |
|
| 451 |
+ request.app.bundle_id(), |
|
| 452 |
+ &nested_bundle.bundle_id, |
|
| 453 |
+ &request.team_id, |
|
| 454 |
+ ); |
|
| 455 |
+ let (nested_app_id, account_update) = resolve_signing_app_id( |
|
| 456 |
+ request.developer_context.clone(), |
|
| 457 |
+ &request.team_id, |
|
| 458 |
+ &team_app_ids, |
|
| 459 |
+ true, |
|
| 460 |
+ None, |
|
| 461 |
+ &effective_nested_bundle_id, |
|
| 462 |
+ &nested_bundle.name, |
|
| 463 |
+ ) |
|
| 464 |
+ .await?; |
|
| 465 |
+ let expected_identifier = effective_nested_bundle_id.clone(); |
|
| 466 |
+ if !app_id_matches_identifier(&nested_app_id.identifier, &expected_identifier) {
|
|
| 467 |
+ return Err(AppError::from(format!( |
|
| 468 |
+ "App ID {} does not match nested bundle identifier {}.",
|
|
| 469 |
+ nested_app_id.identifier, nested_bundle.bundle_id |
|
| 470 |
+ ))); |
|
| 471 |
+ } |
|
| 472 |
+ if nested_app_id.developer_id.trim().is_empty() {
|
|
| 473 |
+ return Err(AppError::from(format!( |
|
| 474 |
+ "App ID {} is missing its Xcode identifier.",
|
|
| 475 |
+ nested_app_id.identifier |
|
| 476 |
+ ))); |
|
| 477 |
+ } |
|
| 478 |
+ if let Some(account) = account_update {
|
|
| 479 |
+ team_app_ids = team_app_ids_for_account(&account, &request.team_id) |
|
| 480 |
+ .unwrap_or_else(|| team_app_ids.clone()); |
|
| 481 |
+ updated_account = Some(account); |
|
| 482 |
+ } |
|
| 483 |
+ bundle_id_replacements.insert( |
|
| 484 |
+ nested_bundle.bundle_id.clone(), |
|
| 485 |
+ effective_nested_bundle_id.clone(), |
|
| 486 |
+ ); |
|
| 487 |
+ profile_requests.push((effective_nested_bundle_id, nested_app_id.developer_id)); |
|
| 488 |
+ } |
|
| 489 |
+ |
|
| 490 |
+ progress(SignIpaProgress::DownloadingProfile); |
|
| 491 |
+ let mut provisioning_profiles = BTreeMap::new(); |
|
| 492 |
+ for (bundle_id, app_id_id) in profile_requests {
|
|
| 493 |
+ let profile = download_provisioning_profile( |
|
| 494 |
+ request.developer_context.clone(), |
|
| 495 |
+ request.team_id.clone(), |
|
| 496 |
+ app_id_id, |
|
| 497 |
+ ) |
|
| 498 |
+ .await?; |
|
| 499 |
+ provisioning_profiles.insert(bundle_id, profile.bytes); |
|
| 500 |
+ } |
|
| 501 |
+ |
|
| 502 |
+ let mut metadata = domain_app_metadata(&request.app); |
|
| 503 |
+ metadata.bundle_id = effective_bundle_id.clone(); |
|
| 504 |
+ let destination = match request.output {
|
|
| 505 |
+ SignIpaOutput::Ipa(path) => crate::backend::ipa::SigningDestination::Ipa(path), |
|
| 506 |
+ SignIpaOutput::AppBundle => crate::backend::ipa::SigningDestination::AppBundle, |
|
| 507 |
+ }; |
|
| 508 |
+ let artifact = crate::backend::ipa::sign_ipa( |
|
| 509 |
+ crate::backend::ipa::IpaSigningRequest {
|
|
| 510 |
+ source_path: PathBuf::from(&request.app.path), |
|
| 511 |
+ destination, |
|
| 512 |
+ metadata, |
|
| 513 |
+ bundle_id_replacements, |
|
| 514 |
+ icon_override_path: request.app.icon_override_path.as_deref().map(PathBuf::from), |
|
| 515 |
+ team_id: request.team_id, |
|
| 516 |
+ provisioning_profiles, |
|
| 517 |
+ private_key_pem: signing_material.private_key_pem, |
|
| 518 |
+ certificate_der: signing_material.certificate_der, |
|
| 519 |
+ }, |
|
| 520 |
+ move |event| progress(event.into()), |
|
| 521 |
+ ) |
|
| 522 |
+ .await |
|
| 523 |
+ .map_err(AppError::from)?; |
|
| 524 |
+ let artifact = match artifact {
|
|
| 525 |
+ crate::backend::ipa::SigningArtifact::Ipa(path) => SignIpaArtifact::Ipa(path), |
|
| 526 |
+ crate::backend::ipa::SigningArtifact::AppBundle(app) => SignIpaArtifact::AppBundle(app), |
|
| 527 |
+ }; |
|
| 528 |
+ |
|
| 529 |
+ Ok(SignIpaOutcome {
|
|
| 530 |
+ artifact, |
|
| 531 |
+ updated_account, |
|
| 532 |
+ }) |
|
| 533 |
+} |
|
| 534 |
+ |
|
| 535 |
+pub(crate) fn app_id_provisioning_plan( |
|
| 536 |
+ team: &TeamOption, |
|
| 537 |
+ auto_app_id: bool, |
|
| 538 |
+ selected_app_id: Option<AppIdOption>, |
|
| 539 |
+ app: &AppOption, |
|
| 540 |
+) -> AppResult<AppIdProvisioningPlan> {
|
|
| 541 |
+ let mut missing = BTreeMap::<String, String>::new(); |
|
| 542 |
+ let root_identifier = developer_app_identifier(&team.identifier, app.bundle_id()); |
|
| 543 |
+ |
|
| 544 |
+ if auto_app_id {
|
|
| 545 |
+ add_missing_app_id( |
|
| 546 |
+ &mut missing, |
|
| 547 |
+ &team.app_ids, |
|
| 548 |
+ root_identifier, |
|
| 549 |
+ app.name().to_string(), |
|
| 550 |
+ ); |
|
| 551 |
+ } else {
|
|
| 552 |
+ let selected_app_id = selected_app_id.ok_or_else(|| {
|
|
| 553 |
+ AppError::from("Select an App ID in Developer Settings before signing.")
|
|
| 554 |
+ })?; |
|
| 555 |
+ if !app_id_matches_identifier(&selected_app_id.identifier, &root_identifier) {
|
|
| 556 |
+ return Err(AppError::from(format!( |
|
| 557 |
+ "App ID {} does not match bundle identifier {}. Select a matching App ID in Developer Settings.",
|
|
| 558 |
+ selected_app_id.identifier, |
|
| 559 |
+ app.bundle_id() |
|
| 560 |
+ ))); |
|
| 561 |
+ } |
|
| 562 |
+ } |
|
| 563 |
+ |
|
| 564 |
+ for nested_bundle in &app.nested_bundles {
|
|
| 565 |
+ add_missing_app_id( |
|
| 566 |
+ &mut missing, |
|
| 567 |
+ &team.app_ids, |
|
| 568 |
+ effective_nested_bundle_identifier( |
|
| 569 |
+ app.original_bundle_id(), |
|
| 570 |
+ app.bundle_id(), |
|
| 571 |
+ &nested_bundle.bundle_id, |
|
| 572 |
+ &team.identifier, |
|
| 573 |
+ ), |
|
| 574 |
+ nested_bundle.name.clone(), |
|
| 575 |
+ ); |
|
| 576 |
+ } |
|
| 577 |
+ |
|
| 578 |
+ let app_ids = missing |
|
| 579 |
+ .into_iter() |
|
| 580 |
+ .map(|(identifier, name)| AppIdProvisioningItem { name, identifier })
|
|
| 581 |
+ .collect::<Vec<_>>(); |
|
| 582 |
+ if let Some(available) = team.app_id_available_quantity {
|
|
| 583 |
+ if app_ids.len() as u64 > available {
|
|
| 584 |
+ return Err(AppError::from(format!( |
|
| 585 |
+ "Signing requires {} new App IDs, but Apple reports only {available} remaining for {}.",
|
|
| 586 |
+ app_ids.len(), |
|
| 587 |
+ team.name |
|
| 588 |
+ ))); |
|
| 589 |
+ } |
|
| 590 |
+ } |
|
| 591 |
+ |
|
| 592 |
+ Ok(AppIdProvisioningPlan {
|
|
| 593 |
+ app_ids, |
|
| 594 |
+ available_quantity: team.app_id_available_quantity, |
|
| 595 |
+ }) |
|
| 596 |
+} |
|
| 597 |
+ |
|
| 598 |
+fn add_missing_app_id( |
|
| 599 |
+ missing: &mut BTreeMap<String, String>, |
|
| 600 |
+ existing: &[AppIdOption], |
|
| 601 |
+ identifier: String, |
|
| 602 |
+ name: String, |
|
| 603 |
+) {
|
|
| 604 |
+ if !existing |
|
| 605 |
+ .iter() |
|
| 606 |
+ .any(|app_id| app_id.identifier == identifier) |
|
| 607 |
+ {
|
|
| 608 |
+ missing.entry(identifier).or_insert(name); |
|
| 609 |
+ } |
|
| 610 |
+} |
|
| 611 |
+ |
|
| 612 |
+fn team_app_ids_for_account(account: &AccountOption, team_id: &str) -> Option<Vec<AppIdOption>> {
|
|
| 613 |
+ account |
|
| 614 |
+ .teams |
|
| 615 |
+ .iter() |
|
| 616 |
+ .find(|team| team.identifier == team_id) |
|
| 617 |
+ .map(|team| team.app_ids.clone()) |
|
| 618 |
+} |
|
| 619 |
+ |
|
| 620 |
+async fn resolve_signing_app_id( |
|
| 621 |
+ developer_context: DeveloperSessionContext, |
|
| 622 |
+ team_id: &str, |
|
| 623 |
+ team_app_ids: &[AppIdOption], |
|
| 624 |
+ auto_app_id: bool, |
|
| 625 |
+ selected_app_id: Option<AppIdOption>, |
|
| 626 |
+ identifier: &str, |
|
| 627 |
+ app_name: &str, |
|
| 628 |
+) -> AppResult<(AppIdOption, Option<AccountOption>)> {
|
|
| 629 |
+ if let Some(app_id) = |
|
| 630 |
+ existing_signing_app_id(team_app_ids, auto_app_id, selected_app_id, identifier)? |
|
| 631 |
+ {
|
|
| 632 |
+ return Ok((app_id.clone(), None)); |
|
| 633 |
+ } |
|
| 634 |
+ |
|
| 635 |
+ let account = add_app_id( |
|
| 636 |
+ developer_context, |
|
| 637 |
+ team_id.to_string(), |
|
| 638 |
+ identifier.to_string(), |
|
| 639 |
+ app_name.to_string(), |
|
| 640 |
+ ) |
|
| 641 |
+ .await?; |
|
| 642 |
+ let app_id = account |
|
| 643 |
+ .teams |
|
| 644 |
+ .iter() |
|
| 645 |
+ .find(|team| team.identifier == team_id) |
|
| 646 |
+ .and_then(|team| {
|
|
| 647 |
+ team.app_ids |
|
| 648 |
+ .iter() |
|
| 649 |
+ .find(|app_id| app_id.identifier == identifier) |
|
| 650 |
+ }) |
|
| 651 |
+ .cloned() |
|
| 652 |
+ .ok_or_else(|| {
|
|
| 653 |
+ AppError::from(format!( |
|
| 654 |
+ "Apple created App ID {identifier}, but it was not returned by the developer portal. Refresh Developer Settings and try again."
|
|
| 655 |
+ )) |
|
| 656 |
+ })?; |
|
| 657 |
+ Ok((app_id, Some(account))) |
|
| 658 |
+} |
|
| 659 |
+ |
|
| 660 |
+fn existing_signing_app_id( |
|
| 661 |
+ team_app_ids: &[AppIdOption], |
|
| 662 |
+ auto_app_id: bool, |
|
| 663 |
+ selected_app_id: Option<AppIdOption>, |
|
| 664 |
+ automatic_identifier: &str, |
|
| 665 |
+) -> AppResult<Option<AppIdOption>> {
|
|
| 666 |
+ if !auto_app_id {
|
|
| 667 |
+ return selected_app_id.map(Some).ok_or_else(|| {
|
|
| 668 |
+ AppError::from("Select an App ID in Developer Settings before signing.")
|
|
| 669 |
+ }); |
|
| 670 |
+ } |
|
| 671 |
+ |
|
| 672 |
+ Ok(team_app_ids |
|
| 673 |
+ .iter() |
|
| 674 |
+ .find(|app_id| app_id.identifier == automatic_identifier) |
|
| 675 |
+ .cloned()) |
|
| 676 |
+} |
|
| 677 |
+ |
|
| 678 |
+fn developer_app_identifier(team_id: &str, bundle_id: &str) -> String {
|
|
| 679 |
+ effective_bundle_identifier(bundle_id.trim(), team_id) |
|
| 680 |
+} |
|
| 681 |
+ |
|
| 682 |
+fn app_id_matches_identifier(app_id: &str, expected_identifier: &str) -> bool {
|
|
| 683 |
+ app_id == expected_identifier |
|
| 684 |
+ || app_id |
|
| 685 |
+ .strip_suffix('*')
|
|
| 686 |
+ .is_some_and(|prefix| expected_identifier.starts_with(prefix)) |
|
| 687 |
+} |
|
| 688 |
+ |
|
| 160 | 689 |
pub(crate) fn is_ipa_path(path: &Path) -> bool {
|
| 161 | 690 |
selection::is_ipa_path(path) |
| 162 | 691 |
} |
| ... | ... |
@@ -251,6 +780,39 @@ pub(crate) async fn delete_app_id( |
| 251 | 780 |
.map_err(AppError::from) |
| 252 | 781 |
} |
| 253 | 782 |
|
| 783 |
+pub(crate) async fn list_developer_devices( |
|
| 784 |
+ context: DeveloperSessionContext, |
|
| 785 |
+ team_id: String, |
|
| 786 |
+) -> AppResult<Vec<DeveloperDeviceOption>> {
|
|
| 787 |
+ developer_services::list_developer_devices(context.into_backend(), team_id) |
|
| 788 |
+ .await |
|
| 789 |
+ .map(developer_device_options) |
|
| 790 |
+ .map_err(AppError::from) |
|
| 791 |
+} |
|
| 792 |
+ |
|
| 793 |
+pub(crate) async fn add_developer_device( |
|
| 794 |
+ context: DeveloperSessionContext, |
|
| 795 |
+ team_id: String, |
|
| 796 |
+ name: String, |
|
| 797 |
+ udid: String, |
|
| 798 |
+) -> AppResult<Vec<DeveloperDeviceOption>> {
|
|
| 799 |
+ developer_services::add_developer_device(context.into_backend(), team_id, name, udid) |
|
| 800 |
+ .await |
|
| 801 |
+ .map(developer_device_options) |
|
| 802 |
+ .map_err(AppError::from) |
|
| 803 |
+} |
|
| 804 |
+ |
|
| 805 |
+pub(crate) async fn delete_developer_device( |
|
| 806 |
+ context: DeveloperSessionContext, |
|
| 807 |
+ team_id: String, |
|
| 808 |
+ device_id: String, |
|
| 809 |
+) -> AppResult<Vec<DeveloperDeviceOption>> {
|
|
| 810 |
+ developer_services::delete_developer_device(context.into_backend(), team_id, device_id) |
|
| 811 |
+ .await |
|
| 812 |
+ .map(developer_device_options) |
|
| 813 |
+ .map_err(AppError::from) |
|
| 814 |
+} |
|
| 815 |
+ |
|
| 254 | 816 |
pub(crate) async fn create_certificate( |
| 255 | 817 |
context: DeveloperSessionContext, |
| 256 | 818 |
team_id: String, |
| ... | ... |
@@ -300,3 +862,277 @@ pub(crate) async fn download_provisioning_profile( |
| 300 | 862 |
.await |
| 301 | 863 |
.map_err(AppError::from) |
| 302 | 864 |
} |
| 865 |
+ |
|
| 866 |
+#[cfg(test)] |
|
| 867 |
+mod tests {
|
|
| 868 |
+ use super::*; |
|
| 869 |
+ use crate::app::models::{
|
|
| 870 |
+ AppMetadata, EntitlementsSource, NestedBundleOption, SupportedDeviceFamily, |
|
| 871 |
+ }; |
|
| 872 |
+ |
|
| 873 |
+ fn app_id(identifier: &str) -> AppIdOption {
|
|
| 874 |
+ AppIdOption {
|
|
| 875 |
+ developer_id: format!("xcode-{identifier}"),
|
|
| 876 |
+ name: identifier.to_string(), |
|
| 877 |
+ identifier: identifier.to_string(), |
|
| 878 |
+ kind: "Explicit App ID".to_string(), |
|
| 879 |
+ capabilities: Vec::new(), |
|
| 880 |
+ } |
|
| 881 |
+ } |
|
| 882 |
+ |
|
| 883 |
+ fn app(bundle_id: &str, nested_bundle_ids: &[&str]) -> AppOption {
|
|
| 884 |
+ AppOption {
|
|
| 885 |
+ metadata: AppMetadata::sample( |
|
| 886 |
+ "Example", |
|
| 887 |
+ bundle_id, |
|
| 888 |
+ "1.0", |
|
| 889 |
+ "1", |
|
| 890 |
+ "Example", |
|
| 891 |
+ "15.0", |
|
| 892 |
+ vec![SupportedDeviceFamily::IPhone], |
|
| 893 |
+ ), |
|
| 894 |
+ nested_bundles: nested_bundle_ids |
|
| 895 |
+ .iter() |
|
| 896 |
+ .map(|bundle_id| NestedBundleOption {
|
|
| 897 |
+ name: bundle_id.rsplit('.').next().unwrap().to_string(),
|
|
| 898 |
+ bundle_id: (*bundle_id).to_string(), |
|
| 899 |
+ }) |
|
| 900 |
+ .collect(), |
|
| 901 |
+ path: "/tmp/Example.ipa".to_string(), |
|
| 902 |
+ icon_path: None, |
|
| 903 |
+ icon_override_path: None, |
|
| 904 |
+ entitlements: Vec::new(), |
|
| 905 |
+ entitlements_source: EntitlementsSource::GeneratedFallback, |
|
| 906 |
+ entitlement_overrides: None, |
|
| 907 |
+ patches: Vec::new(), |
|
| 908 |
+ } |
|
| 909 |
+ } |
|
| 910 |
+ |
|
| 911 |
+ fn team(app_ids: Vec<AppIdOption>, available: Option<u64>) -> TeamOption {
|
|
| 912 |
+ TeamOption {
|
|
| 913 |
+ name: "Example Team".to_string(), |
|
| 914 |
+ identifier: "TEAM".to_string(), |
|
| 915 |
+ role: "Admin".to_string(), |
|
| 916 |
+ app_id_available_quantity: available, |
|
| 917 |
+ app_id_max_quantity: Some(10), |
|
| 918 |
+ app_ids, |
|
| 919 |
+ certificates: Vec::new(), |
|
| 920 |
+ } |
|
| 921 |
+ } |
|
| 922 |
+ |
|
| 923 |
+ #[test] |
|
| 924 |
+ fn automatic_app_id_uses_exact_team_suffixed_match() {
|
|
| 925 |
+ let app_ids = vec![ |
|
| 926 |
+ app_id("com.example.other.TEAM"),
|
|
| 927 |
+ app_id("com.example.app.TEAM"),
|
|
| 928 |
+ ]; |
|
| 929 |
+ |
|
| 930 |
+ let selected = existing_signing_app_id( |
|
| 931 |
+ &app_ids, |
|
| 932 |
+ true, |
|
| 933 |
+ Some(app_ids[0].clone()), |
|
| 934 |
+ "com.example.app.TEAM", |
|
| 935 |
+ ) |
|
| 936 |
+ .unwrap() |
|
| 937 |
+ .unwrap(); |
|
| 938 |
+ |
|
| 939 |
+ assert_eq!(selected.identifier, "com.example.app.TEAM"); |
|
| 940 |
+ } |
|
| 941 |
+ |
|
| 942 |
+ #[test] |
|
| 943 |
+ fn automatic_app_id_requests_creation_when_exact_match_is_missing() {
|
|
| 944 |
+ let app_ids = vec![app_id("com.example.other.TEAM")];
|
|
| 945 |
+ |
|
| 946 |
+ assert!( |
|
| 947 |
+ existing_signing_app_id(&app_ids, true, None, "com.example.app.TEAM") |
|
| 948 |
+ .unwrap() |
|
| 949 |
+ .is_none() |
|
| 950 |
+ ); |
|
| 951 |
+ } |
|
| 952 |
+ |
|
| 953 |
+ #[test] |
|
| 954 |
+ fn manual_app_id_requires_and_uses_selection() {
|
|
| 955 |
+ let selected = app_id("com.example.manual.TEAM");
|
|
| 956 |
+ assert_eq!( |
|
| 957 |
+ existing_signing_app_id(&[], false, Some(selected), "ignored") |
|
| 958 |
+ .unwrap() |
|
| 959 |
+ .unwrap() |
|
| 960 |
+ .identifier, |
|
| 961 |
+ "com.example.manual.TEAM" |
|
| 962 |
+ ); |
|
| 963 |
+ assert!(existing_signing_app_id(&[], false, None, "ignored").is_err()); |
|
| 964 |
+ } |
|
| 965 |
+ |
|
| 966 |
+ #[test] |
|
| 967 |
+ fn developer_identifier_is_suffixed_only_once() {
|
|
| 968 |
+ assert_eq!( |
|
| 969 |
+ developer_app_identifier("TEAM", "com.example.app"),
|
|
| 970 |
+ "com.example.app.TEAM" |
|
| 971 |
+ ); |
|
| 972 |
+ assert_eq!( |
|
| 973 |
+ developer_app_identifier("TEAM", "com.example.app.TEAM"),
|
|
| 974 |
+ "com.example.app.TEAM" |
|
| 975 |
+ ); |
|
| 976 |
+ } |
|
| 977 |
+ |
|
| 978 |
+ #[test] |
|
| 979 |
+ fn app_id_matching_accepts_exact_and_wildcard_identifiers() {
|
|
| 980 |
+ assert!(app_id_matches_identifier( |
|
| 981 |
+ "TEAM.com.example.app", |
|
| 982 |
+ "TEAM.com.example.app" |
|
| 983 |
+ )); |
|
| 984 |
+ assert!(app_id_matches_identifier( |
|
| 985 |
+ "TEAM.com.example.*", |
|
| 986 |
+ "TEAM.com.example.app" |
|
| 987 |
+ )); |
|
| 988 |
+ assert!(!app_id_matches_identifier( |
|
| 989 |
+ "TEAM.com.other.app", |
|
| 990 |
+ "TEAM.com.example.app" |
|
| 991 |
+ )); |
|
| 992 |
+ } |
|
| 993 |
+ |
|
| 994 |
+ #[test] |
|
| 995 |
+ fn provisioning_plan_counts_unique_missing_root_and_nested_app_ids() {
|
|
| 996 |
+ let app = app( |
|
| 997 |
+ "com.example.app", |
|
| 998 |
+ &[ |
|
| 999 |
+ "com.example.app.widget", |
|
| 1000 |
+ "com.example.app.widget", |
|
| 1001 |
+ "com.example.app.share", |
|
| 1002 |
+ ], |
|
| 1003 |
+ ); |
|
| 1004 |
+ let team = team(vec![app_id("com.example.app.TEAM.widget")], Some(5));
|
|
| 1005 |
+ |
|
| 1006 |
+ let plan = app_id_provisioning_plan(&team, true, None, &app).unwrap(); |
|
| 1007 |
+ |
|
| 1008 |
+ assert_eq!( |
|
| 1009 |
+ plan.app_ids |
|
| 1010 |
+ .iter() |
|
| 1011 |
+ .map(|app_id| app_id.identifier.as_str()) |
|
| 1012 |
+ .collect::<Vec<_>>(), |
|
| 1013 |
+ vec!["com.example.app.TEAM", "com.example.app.TEAM.share"] |
|
| 1014 |
+ ); |
|
| 1015 |
+ assert_eq!(plan.available_quantity, Some(5)); |
|
| 1016 |
+ assert_eq!(plan.remaining_after_signing(), Some(3)); |
|
| 1017 |
+ } |
|
| 1018 |
+ |
|
| 1019 |
+ #[test] |
|
| 1020 |
+ fn provisioning_plan_rebases_nested_ids_when_root_bundle_id_is_overridden() {
|
|
| 1021 |
+ let mut app = app("com.example.app", &["com.example.app.SubApp.Extension"]);
|
|
| 1022 |
+ app.metadata |
|
| 1023 |
+ .bundle_id |
|
| 1024 |
+ .set_override("altcom.example.app".to_string());
|
|
| 1025 |
+ |
|
| 1026 |
+ let plan = app_id_provisioning_plan(&team(Vec::new(), Some(5)), true, None, &app).unwrap(); |
|
| 1027 |
+ |
|
| 1028 |
+ assert_eq!( |
|
| 1029 |
+ plan.app_ids |
|
| 1030 |
+ .iter() |
|
| 1031 |
+ .map(|app_id| app_id.identifier.as_str()) |
|
| 1032 |
+ .collect::<Vec<_>>(), |
|
| 1033 |
+ vec![ |
|
| 1034 |
+ "altcom.example.app.TEAM", |
|
| 1035 |
+ "altcom.example.app.TEAM.SubApp.Extension", |
|
| 1036 |
+ ] |
|
| 1037 |
+ ); |
|
| 1038 |
+ } |
|
| 1039 |
+ |
|
| 1040 |
+ #[test] |
|
| 1041 |
+ fn provisioning_plan_does_not_create_manually_selected_root_app_id() {
|
|
| 1042 |
+ let app = app("com.example.app", &["com.example.app.widget"]);
|
|
| 1043 |
+ let selected = app_id("com.example.app.TEAM");
|
|
| 1044 |
+ let team = team(vec![selected.clone()], Some(4)); |
|
| 1045 |
+ |
|
| 1046 |
+ let plan = app_id_provisioning_plan(&team, false, Some(selected), &app).unwrap(); |
|
| 1047 |
+ |
|
| 1048 |
+ assert_eq!(plan.app_ids.len(), 1); |
|
| 1049 |
+ assert_eq!(plan.app_ids[0].identifier, "com.example.app.TEAM.widget"); |
|
| 1050 |
+ } |
|
| 1051 |
+ |
|
| 1052 |
+ #[test] |
|
| 1053 |
+ fn provisioning_plan_rejects_insufficient_refreshed_quota() {
|
|
| 1054 |
+ let app = app("com.example.app", &["com.example.app.widget"]);
|
|
| 1055 |
+ let error = |
|
| 1056 |
+ app_id_provisioning_plan(&team(Vec::new(), Some(1)), true, None, &app).unwrap_err(); |
|
| 1057 |
+ |
|
| 1058 |
+ assert!(error.user_message().contains("requires 2 new App IDs"));
|
|
| 1059 |
+ assert!(error.user_message().contains("only 1 remaining"));
|
|
| 1060 |
+ } |
|
| 1061 |
+ |
|
| 1062 |
+ #[test] |
|
| 1063 |
+ fn signing_progress_is_monotonic_across_pipeline_stages() {
|
|
| 1064 |
+ let events = [ |
|
| 1065 |
+ SignIpaProgress::Preparing, |
|
| 1066 |
+ SignIpaProgress::ResolvingAppId, |
|
| 1067 |
+ SignIpaProgress::DownloadingProfile, |
|
| 1068 |
+ SignIpaProgress::Extracting {
|
|
| 1069 |
+ completed: 0, |
|
| 1070 |
+ total: 10, |
|
| 1071 |
+ }, |
|
| 1072 |
+ SignIpaProgress::Extracting {
|
|
| 1073 |
+ completed: 10, |
|
| 1074 |
+ total: 10, |
|
| 1075 |
+ }, |
|
| 1076 |
+ SignIpaProgress::Patching, |
|
| 1077 |
+ SignIpaProgress::Signing, |
|
| 1078 |
+ SignIpaProgress::Packaging {
|
|
| 1079 |
+ completed: 0, |
|
| 1080 |
+ total: 20, |
|
| 1081 |
+ }, |
|
| 1082 |
+ SignIpaProgress::Packaging {
|
|
| 1083 |
+ completed: 20, |
|
| 1084 |
+ total: 20, |
|
| 1085 |
+ }, |
|
| 1086 |
+ SignIpaProgress::Saving, |
|
| 1087 |
+ SignIpaProgress::Ready, |
|
| 1088 |
+ ]; |
|
| 1089 |
+ let values = events.map(SignIpaProgress::progress); |
|
| 1090 |
+ |
|
| 1091 |
+ assert!(values.windows(2).all(|window| window[0] <= window[1])); |
|
| 1092 |
+ assert_eq!(SignIpaProgress::Saving.progress(), 0.98); |
|
| 1093 |
+ assert_eq!( |
|
| 1094 |
+ SignIpaProgress::Extracting {
|
|
| 1095 |
+ completed: 4, |
|
| 1096 |
+ total: 10 |
|
| 1097 |
+ } |
|
| 1098 |
+ .label(), |
|
| 1099 |
+ "Extracting IPA (4/10)" |
|
| 1100 |
+ ); |
|
| 1101 |
+ } |
|
| 1102 |
+ |
|
| 1103 |
+ #[test] |
|
| 1104 |
+ fn installation_progress_is_monotonic_and_reports_transfer_size() {
|
|
| 1105 |
+ let events = [ |
|
| 1106 |
+ InstallAppProgress::Connecting, |
|
| 1107 |
+ InstallAppProgress::Uploading {
|
|
| 1108 |
+ transferred_bytes: 0, |
|
| 1109 |
+ total_bytes: 10 * 1024 * 1024, |
|
| 1110 |
+ completed_files: 0, |
|
| 1111 |
+ total_files: 20, |
|
| 1112 |
+ }, |
|
| 1113 |
+ InstallAppProgress::Uploading {
|
|
| 1114 |
+ transferred_bytes: 10 * 1024 * 1024, |
|
| 1115 |
+ total_bytes: 10 * 1024 * 1024, |
|
| 1116 |
+ completed_files: 20, |
|
| 1117 |
+ total_files: 20, |
|
| 1118 |
+ }, |
|
| 1119 |
+ InstallAppProgress::Installing { percent: 0 },
|
|
| 1120 |
+ InstallAppProgress::Installing { percent: 100 },
|
|
| 1121 |
+ InstallAppProgress::Finalizing, |
|
| 1122 |
+ ]; |
|
| 1123 |
+ let values = events.map(InstallAppProgress::progress); |
|
| 1124 |
+ |
|
| 1125 |
+ assert!(values.windows(2).all(|window| window[0] <= window[1])); |
|
| 1126 |
+ assert_eq!(InstallAppProgress::Finalizing.progress(), 0.99); |
|
| 1127 |
+ assert_eq!( |
|
| 1128 |
+ InstallAppProgress::Uploading {
|
|
| 1129 |
+ transferred_bytes: 5 * 1024 * 1024, |
|
| 1130 |
+ total_bytes: 10 * 1024 * 1024, |
|
| 1131 |
+ completed_files: 10, |
|
| 1132 |
+ total_files: 20, |
|
| 1133 |
+ } |
|
| 1134 |
+ .label(), |
|
| 1135 |
+ "Uploading app files (10/20, 5.0 MB / 10.0 MB)" |
|
| 1136 |
+ ); |
|
| 1137 |
+ } |
|
| 1138 |
+} |
| ... | ... |
@@ -4,23 +4,56 @@ pub(crate) fn effective_entitlements_for_app( |
| 4 | 4 |
app: &AppOption, |
| 5 | 5 |
team_id: &str, |
| 6 | 6 |
) -> Vec<AppEntitlement> {
|
| 7 |
+ let bundle_id = effective_bundle_identifier_for_app(app, team_id); |
|
| 7 | 8 |
effective_entitlements( |
| 8 | 9 |
app.entitlements_source, |
| 9 | 10 |
&app.entitlements, |
| 10 | 11 |
app.entitlement_overrides.as_deref(), |
| 11 |
- app.bundle_id(), |
|
| 12 |
+ &bundle_id, |
|
| 12 | 13 |
team_id, |
| 13 | 14 |
) |
| 14 | 15 |
} |
| 15 | 16 |
|
| 17 |
+pub(crate) fn effective_bundle_identifier_for_app(app: &AppOption, team_id: &str) -> String {
|
|
| 18 |
+ effective_bundle_identifier(app.bundle_id(), team_id) |
|
| 19 |
+} |
|
| 20 |
+ |
|
| 21 |
+pub(crate) fn effective_bundle_identifier(bundle_id: &str, team_id: &str) -> String {
|
|
| 22 |
+ let team_id = team_id.trim(); |
|
| 23 |
+ if team_id.is_empty() |
|
| 24 |
+ || bundle_id |
|
| 25 |
+ .strip_suffix(team_id) |
|
| 26 |
+ .is_some_and(|prefix| prefix.ends_with('.'))
|
|
| 27 |
+ {
|
|
| 28 |
+ bundle_id.to_string() |
|
| 29 |
+ } else {
|
|
| 30 |
+ format!("{bundle_id}.{team_id}")
|
|
| 31 |
+ } |
|
| 32 |
+} |
|
| 33 |
+ |
|
| 34 |
+pub(crate) fn effective_nested_bundle_identifier( |
|
| 35 |
+ original_root_bundle_id: &str, |
|
| 36 |
+ root_bundle_id: &str, |
|
| 37 |
+ nested_bundle_id: &str, |
|
| 38 |
+ team_id: &str, |
|
| 39 |
+) -> String {
|
|
| 40 |
+ let effective_root = effective_bundle_identifier(root_bundle_id, team_id); |
|
| 41 |
+ nested_bundle_id |
|
| 42 |
+ .strip_prefix(original_root_bundle_id) |
|
| 43 |
+ .filter(|suffix| suffix.starts_with('.'))
|
|
| 44 |
+ .map(|suffix| format!("{effective_root}{suffix}"))
|
|
| 45 |
+ .unwrap_or_else(|| effective_bundle_identifier(nested_bundle_id, team_id)) |
|
| 46 |
+} |
|
| 47 |
+ |
|
| 16 | 48 |
pub(crate) fn default_effective_entitlements_for_app( |
| 17 | 49 |
app: &AppOption, |
| 18 | 50 |
team_id: &str, |
| 19 | 51 |
) -> Vec<AppEntitlement> {
|
| 52 |
+ let bundle_id = effective_bundle_identifier_for_app(app, team_id); |
|
| 20 | 53 |
default_effective_entitlements( |
| 21 | 54 |
app.entitlements_source, |
| 22 | 55 |
&app.entitlements, |
| 23 |
- app.bundle_id(), |
|
| 56 |
+ &bundle_id, |
|
| 24 | 57 |
team_id, |
| 25 | 58 |
) |
| 26 | 59 |
} |
| ... | ... |
@@ -93,11 +126,8 @@ fn team_adjusted_entitlement( |
| 93 | 126 |
let mut entitlement = entitlement.clone(); |
| 94 | 127 |
match entitlement.key.as_str() {
|
| 95 | 128 |
"application-identifier" => {
|
| 96 |
- entitlement.value = EntitlementValue::String(team_prefixed_identifier( |
|
| 97 |
- &entitlement.value.edit_text(), |
|
| 98 |
- bundle_id, |
|
| 99 |
- team_id, |
|
| 100 |
- )); |
|
| 129 |
+ entitlement.value = |
|
| 130 |
+ EntitlementValue::String(team_prefixed_bundle_identifier(bundle_id, team_id)); |
|
| 101 | 131 |
} |
| 102 | 132 |
"com.apple.developer.team-identifier" => {
|
| 103 | 133 |
entitlement.value = EntitlementValue::String(team_id.to_string()); |
| ... | ... |
@@ -151,9 +181,13 @@ fn team_prefixed_identifier(value: &str, bundle_id: &str, team_id: &str) -> Stri |
| 151 | 181 |
team_prefixed_bundle_identifier(suffix, team_id) |
| 152 | 182 |
} |
| 153 | 183 |
|
| 154 |
-fn team_prefixed_bundle_identifier(bundle_id: &str, team_id: &str) -> String {
|
|
| 184 |
+pub(crate) fn team_prefixed_bundle_identifier(bundle_id: &str, team_id: &str) -> String {
|
|
| 155 | 185 |
let team_id = team_id.trim(); |
| 156 |
- if team_id.is_empty() {
|
|
| 186 |
+ if team_id.is_empty() |
|
| 187 |
+ || bundle_id |
|
| 188 |
+ .strip_prefix(team_id) |
|
| 189 |
+ .is_some_and(|suffix| suffix.starts_with('.'))
|
|
| 190 |
+ {
|
|
| 157 | 191 |
bundle_id.to_string() |
| 158 | 192 |
} else {
|
| 159 | 193 |
format!("{team_id}.{bundle_id}")
|
| ... | ... |
@@ -190,6 +224,48 @@ mod tests {
|
| 190 | 224 |
assert!(!text.contains("TEAMID"));
|
| 191 | 225 |
} |
| 192 | 226 |
|
| 227 |
+ #[test] |
|
| 228 |
+ fn team_prefixed_bundle_identifier_is_idempotent() {
|
|
| 229 |
+ assert_eq!( |
|
| 230 |
+ team_prefixed_bundle_identifier("com.example.app", "TEAM123"),
|
|
| 231 |
+ "TEAM123.com.example.app" |
|
| 232 |
+ ); |
|
| 233 |
+ assert_eq!( |
|
| 234 |
+ team_prefixed_bundle_identifier("TEAM123.com.example.app", "TEAM123"),
|
|
| 235 |
+ "TEAM123.com.example.app" |
|
| 236 |
+ ); |
|
| 237 |
+ } |
|
| 238 |
+ |
|
| 239 |
+ #[test] |
|
| 240 |
+ fn effective_bundle_identifiers_match_sideloader_layout() {
|
|
| 241 |
+ assert_eq!( |
|
| 242 |
+ effective_bundle_identifier("com.example.app", "TEAM123"),
|
|
| 243 |
+ "com.example.app.TEAM123" |
|
| 244 |
+ ); |
|
| 245 |
+ assert_eq!( |
|
| 246 |
+ effective_bundle_identifier("com.example.app.TEAM123", "TEAM123"),
|
|
| 247 |
+ "com.example.app.TEAM123" |
|
| 248 |
+ ); |
|
| 249 |
+ assert_eq!( |
|
| 250 |
+ effective_nested_bundle_identifier( |
|
| 251 |
+ "com.example.app", |
|
| 252 |
+ "com.example.app", |
|
| 253 |
+ "com.example.app.widget", |
|
| 254 |
+ "TEAM123" |
|
| 255 |
+ ), |
|
| 256 |
+ "com.example.app.TEAM123.widget" |
|
| 257 |
+ ); |
|
| 258 |
+ assert_eq!( |
|
| 259 |
+ effective_nested_bundle_identifier( |
|
| 260 |
+ "com.example.app", |
|
| 261 |
+ "altcom.example.app", |
|
| 262 |
+ "com.example.app.SubApp.Extension", |
|
| 263 |
+ "TEAM123" |
|
| 264 |
+ ), |
|
| 265 |
+ "altcom.example.app.TEAM123.SubApp.Extension" |
|
| 266 |
+ ); |
|
| 267 |
+ } |
|
| 268 |
+ |
|
| 193 | 269 |
#[test] |
| 194 | 270 |
fn embedded_entitlements_are_adjusted_for_selected_team() {
|
| 195 | 271 |
let entitlements = default_effective_entitlements( |
| ... | ... |
@@ -212,7 +288,7 @@ mod tests {
|
| 212 | 288 |
|
| 213 | 289 |
assert_eq!( |
| 214 | 290 |
entitlements[0].value, |
| 215 |
- EntitlementValue::String("TEAM123.com.old.app".into())
|
|
| 291 |
+ EntitlementValue::String("TEAM123.com.example.app".into())
|
|
| 216 | 292 |
); |
| 217 | 293 |
assert_eq!( |
| 218 | 294 |
entitlements[1].value, |
| ... | ... |
@@ -232,6 +308,7 @@ mod tests {
|
| 232 | 308 |
"16.0", |
| 233 | 309 |
vec![SupportedDeviceFamily::IPhone], |
| 234 | 310 |
), |
| 311 |
+ nested_bundles: Vec::new(), |
|
| 235 | 312 |
path: "/tmp/Example.ipa".to_string(), |
| 236 | 313 |
icon_path: None, |
| 237 | 314 |
icon_override_path: None, |
| ... | ... |
@@ -250,7 +327,7 @@ mod tests {
|
| 250 | 327 |
app.entitlements_source, |
| 251 | 328 |
&app.entitlements, |
| 252 | 329 |
app.entitlement_overrides.as_deref(), |
| 253 |
- app.bundle_id(), |
|
| 330 |
+ "com.example.app.TEAM123", |
|
| 254 | 331 |
"TEAM123", |
| 255 | 332 |
) |
| 256 | 333 |
); |
| ... | ... |
@@ -259,9 +336,13 @@ mod tests {
|
| 259 | 336 |
default_effective_entitlements( |
| 260 | 337 |
app.entitlements_source, |
| 261 | 338 |
&app.entitlements, |
| 262 |
- app.bundle_id(), |
|
| 339 |
+ "com.example.app.TEAM123", |
|
| 263 | 340 |
"TEAM123", |
| 264 | 341 |
) |
| 265 | 342 |
); |
| 343 |
+ assert_eq!( |
|
| 344 |
+ effective_entitlements_for_app(&app, "TEAM123")[0].value, |
|
| 345 |
+ EntitlementValue::String("TEAM123.com.example.app.TEAM123".into())
|
|
| 346 |
+ ); |
|
| 266 | 347 |
} |
| 267 | 348 |
} |
| ... | ... |
@@ -29,15 +29,23 @@ pub(crate) struct DevelopmentCertificateOption {
|
| 29 | 29 |
pub(crate) serial_number: String, |
| 30 | 30 |
pub(crate) machine_name: String, |
| 31 | 31 |
pub(crate) private_key_available: bool, |
| 32 |
+ pub(crate) certificate_fingerprint: Option<String>, |
|
| 32 | 33 |
pub(crate) public_key_fingerprint: Option<String>, |
| 33 | 34 |
} |
| 34 | 35 |
|
| 36 |
+#[derive(Clone, Debug, Eq, PartialEq)] |
|
| 37 |
+pub(crate) struct DeveloperDeviceOption {
|
|
| 38 |
+ pub(crate) id: String, |
|
| 39 |
+ pub(crate) name: String, |
|
| 40 |
+ pub(crate) udid: String, |
|
| 41 |
+} |
|
| 42 |
+ |
|
| 35 | 43 |
impl DevelopmentCertificateOption {
|
| 36 | 44 |
pub(crate) fn private_key_status(&self) -> &'static str {
|
| 37 | 45 |
if self.private_key_available {
|
| 38 |
- "Private key available" |
|
| 46 |
+ "Managed private key available" |
|
| 39 | 47 |
} else {
|
| 40 |
- "Private key missing" |
|
| 48 |
+ "Managed private key missing" |
|
| 41 | 49 |
} |
| 42 | 50 |
} |
| 43 | 51 |
|
| ... | ... |
@@ -428,6 +436,7 @@ pub(crate) enum EntitlementsSource {
|
| 428 | 436 |
#[derive(Clone, Debug)] |
| 429 | 437 |
pub(crate) struct AppOption {
|
| 430 | 438 |
pub(crate) metadata: AppMetadata, |
| 439 |
+ pub(crate) nested_bundles: Vec<NestedBundleOption>, |
|
| 431 | 440 |
pub(crate) path: String, |
| 432 | 441 |
pub(crate) icon_path: Option<String>, |
| 433 | 442 |
pub(crate) icon_override_path: Option<String>, |
| ... | ... |
@@ -437,6 +446,12 @@ pub(crate) struct AppOption {
|
| 437 | 446 |
pub(crate) patches: Vec<PatchOption>, |
| 438 | 447 |
} |
| 439 | 448 |
|
| 449 |
+#[derive(Clone, Debug, Eq, PartialEq)] |
|
| 450 |
+pub(crate) struct NestedBundleOption {
|
|
| 451 |
+ pub(crate) name: String, |
|
| 452 |
+ pub(crate) bundle_id: String, |
|
| 453 |
+} |
|
| 454 |
+ |
|
| 440 | 455 |
impl AppOption {
|
| 441 | 456 |
pub(crate) fn name(&self) -> &String {
|
| 442 | 457 |
self.metadata.name.value() |
| ... | ... |
@@ -446,6 +461,10 @@ impl AppOption {
|
| 446 | 461 |
self.metadata.bundle_id.value() |
| 447 | 462 |
} |
| 448 | 463 |
|
| 464 |
+ pub(crate) fn original_bundle_id(&self) -> &String {
|
|
| 465 |
+ &self.metadata.bundle_id.default |
|
| 466 |
+ } |
|
| 467 |
+ |
|
| 449 | 468 |
pub(crate) fn version(&self) -> &String {
|
| 450 | 469 |
self.metadata.version.value() |
| 451 | 470 |
} |
| ... | ... |
@@ -626,9 +645,17 @@ pub(crate) enum SideloadPhase {
|
| 626 | 645 |
#[derive(Clone, Debug)] |
| 627 | 646 |
pub(crate) enum SideloadOperation {
|
| 628 | 647 |
Idle, |
| 629 |
- Running { phase: SideloadPhase, progress: f32 },
|
|
| 630 |
- Finished, |
|
| 631 |
- Failed { message: String },
|
|
| 648 |
+ Running {
|
|
| 649 |
+ phase: SideloadPhase, |
|
| 650 |
+ progress: f32, |
|
| 651 |
+ detail: String, |
|
| 652 |
+ }, |
|
| 653 |
+ Finished {
|
|
| 654 |
+ message: String, |
|
| 655 |
+ }, |
|
| 656 |
+ Failed {
|
|
| 657 |
+ message: String, |
|
| 658 |
+ }, |
|
| 632 | 659 |
} |
| 633 | 660 |
|
| 634 | 661 |
impl SideloadOperation {
|
| ... | ... |
@@ -640,7 +667,7 @@ impl SideloadOperation {
|
| 640 | 667 |
match self {
|
| 641 | 668 |
Self::Idle => 0., |
| 642 | 669 |
Self::Running { progress, .. } => progress.clamp(0., 1.),
|
| 643 |
- Self::Finished => 1., |
|
| 670 |
+ Self::Finished { .. } => 1.,
|
|
| 644 | 671 |
Self::Failed { .. } => 0.,
|
| 645 | 672 |
} |
| 646 | 673 |
} |
| ... | ... |
@@ -579,6 +579,7 @@ mod tests {
|
| 579 | 579 |
serial_number: serial_number.to_string(), |
| 580 | 580 |
machine_name: "Mac".to_string(), |
| 581 | 581 |
private_key_available: true, |
| 582 |
+ certificate_fingerprint: Some(format!("certificate-fingerprint-{serial_number}")),
|
|
| 582 | 583 |
public_key_fingerprint: Some(format!("fingerprint-{serial_number}")),
|
| 583 | 584 |
} |
| 584 | 585 |
} |
| ... | ... |
@@ -1,13 +1,14 @@ |
| 1 | 1 |
use crate::app::models::{
|
| 2 | 2 |
AccountOption, AdiBackendAvailability, AdiBackendDetail, AdiBackendKind, AdiBackendOption, |
| 3 | 3 |
AdiProvisioningState, AdiRepairAction, AppEntitlement, AppIdCapabilityOption, AppIdOption, |
| 4 |
- AppMetadata, AppOption, DevelopmentCertificateOption, DeviceOption, EntitlementValue, |
|
| 5 |
- EntitlementsSource, MachineIdentity, PatchOption, SupportedDeviceFamily, TeamOption, |
|
| 4 |
+ AppMetadata, AppOption, DeveloperDeviceOption, DevelopmentCertificateOption, DeviceOption, |
|
| 5 |
+ EntitlementValue, EntitlementsSource, MachineIdentity, NestedBundleOption, PatchOption, |
|
| 6 |
+ SupportedDeviceFamily, TeamOption, |
|
| 6 | 7 |
}; |
| 7 | 8 |
use crate::domain::{
|
| 8 | 9 |
adi as domain_adi, device as domain_device, identity as domain_identity, ipa as domain_ipa, |
| 9 | 10 |
DeveloperAccount, DeveloperAppId, DeveloperAppIdCapability, DeveloperCertificate, |
| 10 |
- DeveloperTeam, |
|
| 11 |
+ DeveloperDevice, DeveloperTeam, |
|
| 11 | 12 |
}; |
| 12 | 13 |
|
| 13 | 14 |
pub(crate) fn account_option(account: DeveloperAccount) -> AccountOption {
|
| ... | ... |
@@ -93,6 +94,14 @@ pub(crate) fn app_option(app: domain_ipa::IpaApp) -> AppOption {
|
| 93 | 94 |
.map(supported_device_family) |
| 94 | 95 |
.collect(), |
| 95 | 96 |
), |
| 97 |
+ nested_bundles: app |
|
| 98 |
+ .nested_bundles |
|
| 99 |
+ .into_iter() |
|
| 100 |
+ .map(|bundle| NestedBundleOption {
|
|
| 101 |
+ name: bundle.name, |
|
| 102 |
+ bundle_id: bundle.bundle_id, |
|
| 103 |
+ }) |
|
| 104 |
+ .collect(), |
|
| 96 | 105 |
path: app.path, |
| 97 | 106 |
icon_path: app.icon_path, |
| 98 | 107 |
icon_override_path: None, |
| ... | ... |
@@ -114,6 +123,19 @@ pub(crate) fn device_options(devices: Vec<domain_device::Device>) -> Vec<DeviceO |
| 114 | 123 |
devices.into_iter().map(device_option).collect() |
| 115 | 124 |
} |
| 116 | 125 |
|
| 126 |
+pub(crate) fn developer_device_options( |
|
| 127 |
+ devices: Vec<DeveloperDevice>, |
|
| 128 |
+) -> Vec<DeveloperDeviceOption> {
|
|
| 129 |
+ devices |
|
| 130 |
+ .into_iter() |
|
| 131 |
+ .map(|device| DeveloperDeviceOption {
|
|
| 132 |
+ id: device.id, |
|
| 133 |
+ name: device.name, |
|
| 134 |
+ udid: device.udid, |
|
| 135 |
+ }) |
|
| 136 |
+ .collect() |
|
| 137 |
+} |
|
| 138 |
+ |
|
| 117 | 139 |
pub(crate) fn domain_patch(patch: PatchOption) -> domain_ipa::Patch {
|
| 118 | 140 |
domain_ipa::Patch {
|
| 119 | 141 |
name: patch.name, |
| ... | ... |
@@ -121,6 +143,35 @@ pub(crate) fn domain_patch(patch: PatchOption) -> domain_ipa::Patch {
|
| 121 | 143 |
} |
| 122 | 144 |
} |
| 123 | 145 |
|
| 146 |
+pub(crate) fn domain_app_metadata(app: &AppOption) -> domain_ipa::AppMetadata {
|
|
| 147 |
+ domain_ipa::AppMetadata {
|
|
| 148 |
+ name: app.name().to_string(), |
|
| 149 |
+ bundle_id: app.bundle_id().to_string(), |
|
| 150 |
+ version: app.version().to_string(), |
|
| 151 |
+ build: app.build().to_string(), |
|
| 152 |
+ executable: app.metadata.executable.value().to_string(), |
|
| 153 |
+ minimum_os: app.metadata.minimum_os.value().to_string(), |
|
| 154 |
+ supported_devices: app |
|
| 155 |
+ .supported_devices() |
|
| 156 |
+ .iter() |
|
| 157 |
+ .copied() |
|
| 158 |
+ .map(domain_supported_device_family) |
|
| 159 |
+ .collect(), |
|
| 160 |
+ } |
|
| 161 |
+} |
|
| 162 |
+ |
|
| 163 |
+pub(crate) fn domain_app_entitlements( |
|
| 164 |
+ entitlements: &[AppEntitlement], |
|
| 165 |
+) -> Vec<domain_ipa::AppEntitlement> {
|
|
| 166 |
+ entitlements |
|
| 167 |
+ .iter() |
|
| 168 |
+ .map(|entitlement| domain_ipa::AppEntitlement {
|
|
| 169 |
+ key: entitlement.key.clone(), |
|
| 170 |
+ value: domain_entitlement_value(&entitlement.value), |
|
| 171 |
+ }) |
|
| 172 |
+ .collect() |
|
| 173 |
+} |
|
| 174 |
+ |
|
| 124 | 175 |
fn team_option(team: DeveloperTeam) -> TeamOption {
|
| 125 | 176 |
TeamOption {
|
| 126 | 177 |
name: team.name, |
| ... | ... |
@@ -171,6 +222,7 @@ fn certificate_option(certificate: DeveloperCertificate) -> DevelopmentCertifica |
| 171 | 222 |
serial_number: certificate.serial_number, |
| 172 | 223 |
machine_name: certificate.machine_name, |
| 173 | 224 |
private_key_available: certificate.private_key_available, |
| 225 |
+ certificate_fingerprint: certificate.certificate_fingerprint, |
|
| 174 | 226 |
public_key_fingerprint: certificate.public_key_fingerprint, |
| 175 | 227 |
} |
| 176 | 228 |
} |
| ... | ... |
@@ -230,6 +282,18 @@ fn supported_device_family(family: domain_ipa::SupportedDeviceFamily) -> Support |
| 230 | 282 |
} |
| 231 | 283 |
} |
| 232 | 284 |
|
| 285 |
+fn domain_supported_device_family( |
|
| 286 |
+ family: SupportedDeviceFamily, |
|
| 287 |
+) -> domain_ipa::SupportedDeviceFamily {
|
|
| 288 |
+ match family {
|
|
| 289 |
+ SupportedDeviceFamily::IPhone => domain_ipa::SupportedDeviceFamily::IPhone, |
|
| 290 |
+ SupportedDeviceFamily::IPad => domain_ipa::SupportedDeviceFamily::IPad, |
|
| 291 |
+ SupportedDeviceFamily::AppleTv => domain_ipa::SupportedDeviceFamily::AppleTv, |
|
| 292 |
+ SupportedDeviceFamily::AppleWatch => domain_ipa::SupportedDeviceFamily::AppleWatch, |
|
| 293 |
+ SupportedDeviceFamily::Mac => domain_ipa::SupportedDeviceFamily::Mac, |
|
| 294 |
+ } |
|
| 295 |
+} |
|
| 296 |
+ |
|
| 233 | 297 |
fn app_entitlement(entitlement: domain_ipa::AppEntitlement) -> AppEntitlement {
|
| 234 | 298 |
AppEntitlement {
|
| 235 | 299 |
key: entitlement.key, |
| ... | ... |
@@ -259,6 +323,28 @@ fn entitlement_value(value: domain_ipa::EntitlementValue) -> EntitlementValue {
|
| 259 | 323 |
} |
| 260 | 324 |
} |
| 261 | 325 |
|
| 326 |
+fn domain_entitlement_value(value: &EntitlementValue) -> domain_ipa::EntitlementValue {
|
|
| 327 |
+ match value {
|
|
| 328 |
+ EntitlementValue::String(value) => domain_ipa::EntitlementValue::String(value.clone()), |
|
| 329 |
+ EntitlementValue::Boolean(value) => domain_ipa::EntitlementValue::Boolean(*value), |
|
| 330 |
+ EntitlementValue::Integer(value) => domain_ipa::EntitlementValue::Integer(*value), |
|
| 331 |
+ EntitlementValue::Number(value) => domain_ipa::EntitlementValue::Number(*value), |
|
| 332 |
+ EntitlementValue::Array(values) => domain_ipa::EntitlementValue::Array( |
|
| 333 |
+ values.iter().map(domain_entitlement_value).collect(), |
|
| 334 |
+ ), |
|
| 335 |
+ EntitlementValue::Dictionary(values) => domain_ipa::EntitlementValue::Dictionary( |
|
| 336 |
+ values |
|
| 337 |
+ .iter() |
|
| 338 |
+ .map(|(key, value)| (key.clone(), domain_entitlement_value(value))) |
|
| 339 |
+ .collect(), |
|
| 340 |
+ ), |
|
| 341 |
+ EntitlementValue::Data(value) => domain_ipa::EntitlementValue::Data(value.clone()), |
|
| 342 |
+ EntitlementValue::Date(value) => domain_ipa::EntitlementValue::Date(value.clone()), |
|
| 343 |
+ EntitlementValue::Uid(value) => domain_ipa::EntitlementValue::Uid(*value), |
|
| 344 |
+ EntitlementValue::Unknown(value) => domain_ipa::EntitlementValue::Unknown(value.clone()), |
|
| 345 |
+ } |
|
| 346 |
+} |
|
| 347 |
+ |
|
| 262 | 348 |
fn entitlements_source(source: domain_ipa::EntitlementsSource) -> EntitlementsSource {
|
| 263 | 349 |
match source {
|
| 264 | 350 |
domain_ipa::EntitlementsSource::Embedded => EntitlementsSource::Embedded, |
| ... | ... |
@@ -313,6 +399,7 @@ mod tests {
|
| 313 | 399 |
serial_number: "SERIAL".to_string(), |
| 314 | 400 |
machine_name: "Mac".to_string(), |
| 315 | 401 |
private_key_available: true, |
| 402 |
+ certificate_fingerprint: Some("certificate-fingerprint".to_string()),
|
|
| 316 | 403 |
public_key_fingerprint: Some("fingerprint".to_string()),
|
| 317 | 404 |
}], |
| 318 | 405 |
}], |
| ... | ... |
@@ -377,6 +464,19 @@ mod tests {
|
| 377 | 464 |
assert_eq!(options[0].connection, "USB"); |
| 378 | 465 |
} |
| 379 | 466 |
|
| 467 |
+ #[test] |
|
| 468 |
+ fn developer_device_maps_to_settings_option() {
|
|
| 469 |
+ let options = developer_device_options(vec![DeveloperDevice {
|
|
| 470 |
+ id: "portal-device-id".to_string(), |
|
| 471 |
+ name: "Development iPhone".to_string(), |
|
| 472 |
+ udid: "0000000000000000000000000000000000000000".to_string(), |
|
| 473 |
+ }]); |
|
| 474 |
+ |
|
| 475 |
+ assert_eq!(options[0].id, "portal-device-id"); |
|
| 476 |
+ assert_eq!(options[0].name, "Development iPhone"); |
|
| 477 |
+ assert_eq!(options[0].udid.len(), 40); |
|
| 478 |
+ } |
|
| 479 |
+ |
|
| 380 | 480 |
#[test] |
| 381 | 481 |
fn ipa_app_maps_to_ui_app_option_at_app_boundary() {
|
| 382 | 482 |
let option = app_option(domain_ipa::IpaApp {
|
| ... | ... |
@@ -392,6 +492,10 @@ mod tests {
|
| 392 | 492 |
domain_ipa::SupportedDeviceFamily::IPad, |
| 393 | 493 |
], |
| 394 | 494 |
}, |
| 495 |
+ nested_bundles: vec![domain_ipa::NestedBundle {
|
|
| 496 |
+ name: "Widget".to_string(), |
|
| 497 |
+ bundle_id: "com.example.app.Widget".to_string(), |
|
| 498 |
+ }], |
|
| 395 | 499 |
path: "/tmp/Example.ipa".to_string(), |
| 396 | 500 |
icon_path: Some("/tmp/icon.png".to_string()),
|
| 397 | 501 |
entitlements: vec![domain_ipa::AppEntitlement {
|
| ... | ... |
@@ -407,6 +511,7 @@ mod tests {
|
| 407 | 511 |
|
| 408 | 512 |
assert_eq!(option.name(), "Example"); |
| 409 | 513 |
assert_eq!(option.bundle_id(), "com.example.app"); |
| 514 |
+ assert_eq!(option.nested_bundles[0].bundle_id, "com.example.app.Widget"); |
|
| 410 | 515 |
assert_eq!( |
| 411 | 516 |
option.supported_devices(), |
| 412 | 517 |
&[SupportedDeviceFamily::IPhone, SupportedDeviceFamily::IPad] |
| ... | ... |
@@ -1,12 +1,14 @@ |
| 1 | 1 |
pub(crate) use crate::backend::developer::cache::load_cached_account_options; |
| 2 | 2 |
pub(crate) use crate::backend::developer::repository::{
|
| 3 |
- add_developer_app_id, create_developer_certificate, delete_account_cache, |
|
| 4 |
- delete_developer_app_id, developer_secondary_action_not_supported, |
|
| 3 |
+ add_developer_app_id, add_developer_device, create_developer_certificate, delete_account_cache, |
|
| 4 |
+ delete_developer_app_id, delete_developer_device, developer_secondary_action_not_supported, |
|
| 5 | 5 |
download_developer_provisioning_profile, import_developer_certificate_private_key, |
| 6 |
- login_developer_account, refresh_developer_account, revoke_developer_certificate, |
|
| 7 |
- update_developer_app_id, DeveloperAccountRefreshRequest, DeveloperAppIdAddRequest, |
|
| 8 |
- DeveloperAppIdCapabilityUpdate, DeveloperAppIdDeleteRequest, DeveloperAppIdUpdateRequest, |
|
| 9 |
- DeveloperCertificateCreateRequest, DeveloperCertificatePrivateKeyImportRequest, |
|
| 10 |
- DeveloperCertificateRevokeRequest, DeveloperLoginOutcome, DeveloperLoginRequest, |
|
| 11 |
- DeveloperProvisioningProfileDownloadRequest, DownloadedProvisioningProfile, |
|
| 6 |
+ list_developer_devices, login_developer_account, refresh_developer_account, |
|
| 7 |
+ revoke_developer_certificate, update_developer_app_id, DeveloperAccountRefreshRequest, |
|
| 8 |
+ DeveloperAppIdAddRequest, DeveloperAppIdCapabilityUpdate, DeveloperAppIdDeleteRequest, |
|
| 9 |
+ DeveloperAppIdUpdateRequest, DeveloperCertificateCreateRequest, |
|
| 10 |
+ DeveloperCertificatePrivateKeyImportRequest, DeveloperCertificateRevokeRequest, |
|
| 11 |
+ DeveloperDeviceAddRequest, DeveloperDeviceDeleteRequest, DeveloperDeviceListRequest, |
|
| 12 |
+ DeveloperLoginOutcome, DeveloperLoginRequest, DeveloperProvisioningProfileDownloadRequest, |
|
| 13 |
+ DownloadedProvisioningProfile, |
|
| 12 | 14 |
}; |
| ... | ... |
@@ -91,6 +91,8 @@ pub(crate) struct CachedDevelopmentCertificate {
|
| 91 | 91 |
pub(crate) machine_name: String, |
| 92 | 92 |
pub(crate) private_key_available: bool, |
| 93 | 93 |
#[serde(default, skip_serializing_if = "Option::is_none")] |
| 94 |
+ pub(crate) certificate_fingerprint: Option<String>, |
|
| 95 |
+ #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 94 | 96 |
pub(crate) public_key_fingerprint: Option<String>, |
| 95 | 97 |
} |
| 96 | 98 |
|
| ... | ... |
@@ -199,6 +201,7 @@ impl From<CachedDevelopmentCertificate> for DeveloperCertificate {
|
| 199 | 201 |
serial_number: certificate.serial_number, |
| 200 | 202 |
machine_name: certificate.machine_name, |
| 201 | 203 |
private_key_available: certificate.private_key_available, |
| 204 |
+ certificate_fingerprint: certificate.certificate_fingerprint, |
|
| 202 | 205 |
public_key_fingerprint: certificate.public_key_fingerprint, |
| 203 | 206 |
} |
| 204 | 207 |
} |
| ... | ... |
@@ -440,6 +443,7 @@ mod tests {
|
| 440 | 443 |
serial_number: id.to_string(), |
| 441 | 444 |
machine_name: "Machine".to_string(), |
| 442 | 445 |
private_key_available: false, |
| 446 |
+ certificate_fingerprint: None, |
|
| 443 | 447 |
public_key_fingerprint: None, |
| 444 | 448 |
}], |
| 445 | 449 |
} |
| ... | ... |
@@ -471,4 +475,21 @@ mod tests {
|
| 471 | 475 |
assert_eq!(account.teams.len(), 2); |
| 472 | 476 |
assert_eq!(account.teams[1].id, "TEAM2"); |
| 473 | 477 |
} |
| 478 |
+ |
|
| 479 |
+ #[test] |
|
| 480 |
+ fn old_certificate_cache_entries_need_a_certificate_refresh() {
|
|
| 481 |
+ let certificate: CachedDevelopmentCertificate = toml::from_str( |
|
| 482 |
+ r#" |
|
| 483 |
+id = "cert-id" |
|
| 484 |
+name = "Apple Development" |
|
| 485 |
+serial_number = "serial" |
|
| 486 |
+machine_name = "Mac" |
|
| 487 |
+private_key_available = true |
|
| 488 |
+public_key_fingerprint = "0123456789012345678901234567890123456789" |
|
| 489 |
+"#, |
|
| 490 |
+ ) |
|
| 491 |
+ .unwrap(); |
|
| 492 |
+ |
|
| 493 |
+ assert_eq!(certificate.certificate_fingerprint, None); |
|
| 494 |
+ } |
|
| 474 | 495 |
} |
| ... | ... |
@@ -6,6 +6,9 @@ use std::path::{Path, PathBuf};
|
| 6 | 6 |
use std::process::{Command, Stdio};
|
| 7 | 7 |
use uuid::Uuid; |
| 8 | 8 |
|
| 9 |
+#[cfg(unix)] |
|
| 10 |
+use std::os::unix::fs::PermissionsExt; |
|
| 11 |
+ |
|
| 9 | 12 |
#[derive(Clone, Debug)] |
| 10 | 13 |
pub(crate) struct GeneratedCertificateSigningRequest {
|
| 11 | 14 |
pub(crate) machine_id: String, |
| ... | ... |
@@ -15,6 +18,11 @@ pub(crate) struct GeneratedCertificateSigningRequest {
|
| 15 | 18 |
pub(crate) private_key_pem: Vec<u8>, |
| 16 | 19 |
} |
| 17 | 20 |
|
| 21 |
+pub(crate) struct AppManagedSigningMaterial {
|
|
| 22 |
+ pub(crate) private_key_pem: Vec<u8>, |
|
| 23 |
+ pub(crate) certificate_der: Vec<u8>, |
|
| 24 |
+} |
|
| 25 |
+ |
|
| 18 | 26 |
pub(crate) fn generate_development_certificate_signing_request( |
| 19 | 27 |
) -> BackendResult<GeneratedCertificateSigningRequest> {
|
| 20 | 28 |
let machine_id = Uuid::new_v4().to_string().to_uppercase(); |
| ... | ... |
@@ -124,29 +132,50 @@ pub(crate) fn app_managed_private_key_fingerprints() -> Vec<String> {
|
| 124 | 132 |
.collect() |
| 125 | 133 |
} |
| 126 | 134 |
|
| 127 |
-#[cfg(target_os = "macos")] |
|
| 128 |
-pub(crate) fn local_code_signing_identity_fingerprints() -> Vec<String> {
|
|
| 129 |
- let output = match Command::new("security")
|
|
| 130 |
- .args(["find-identity", "-v", "-p", "codesigning"]) |
|
| 131 |
- .output() |
|
| 132 |
- {
|
|
| 133 |
- Ok(output) if output.status.success() => output, |
|
| 134 |
- _ => return Vec::new(), |
|
| 135 |
- }; |
|
| 136 |
- |
|
| 137 |
- String::from_utf8_lossy(&output.stdout) |
|
| 138 |
- .split_whitespace() |
|
| 139 |
- .filter_map(|part| {
|
|
| 140 |
- let fingerprint = part.trim(); |
|
| 141 |
- (fingerprint.len() == 40 && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit())) |
|
| 142 |
- .then(|| fingerprint.to_ascii_uppercase()) |
|
| 135 |
+pub(crate) fn save_app_managed_certificate( |
|
| 136 |
+ fingerprint: &str, |
|
| 137 |
+ certificate_der: &[u8], |
|
| 138 |
+) -> BackendResult<()> {
|
|
| 139 |
+ let certificates_dir = signing_certificates_dir()?; |
|
| 140 |
+ fs::create_dir_all(&certificates_dir).map_err(|source| BackendError::Io {
|
|
| 141 |
+ action: "Create signing certificate folder", |
|
| 142 |
+ path: certificates_dir.clone(), |
|
| 143 |
+ source, |
|
| 144 |
+ })?; |
|
| 145 |
+ let certificate_path = certificates_dir.join(format!("{}.der", safe_fingerprint(fingerprint)?));
|
|
| 146 |
+ fs::write(&certificate_path, certificate_der).map_err(|source| BackendError::Io {
|
|
| 147 |
+ action: "Save signing certificate", |
|
| 148 |
+ path: certificate_path, |
|
| 149 |
+ source, |
|
| 143 | 150 |
}) |
| 144 |
- .collect() |
|
| 145 | 151 |
} |
| 146 | 152 |
|
| 147 |
-#[cfg(not(target_os = "macos"))] |
|
| 148 |
-pub(crate) fn local_code_signing_identity_fingerprints() -> Vec<String> {
|
|
| 149 |
- Vec::new() |
|
| 153 |
+pub(crate) fn load_app_managed_signing_material( |
|
| 154 |
+ certificate_fingerprint: &str, |
|
| 155 |
+ public_key_fingerprint: &str, |
|
| 156 |
+) -> BackendResult<AppManagedSigningMaterial> {
|
|
| 157 |
+ let certificate_path = signing_certificates_dir()?.join(format!( |
|
| 158 |
+ "{}.der",
|
|
| 159 |
+ safe_fingerprint(certificate_fingerprint)? |
|
| 160 |
+ )); |
|
| 161 |
+ let certificate_der = read_signing_resource( |
|
| 162 |
+ &certificate_path, |
|
| 163 |
+ "Read signing certificate", |
|
| 164 |
+ "The selected certificate data is not cached. Refresh Developer Settings, then try signing again.", |
|
| 165 |
+ )?; |
|
| 166 |
+ |
|
| 167 |
+ let private_key_path = |
|
| 168 |
+ certificate_keys_dir()?.join(format!("{}.pem", safe_fingerprint(public_key_fingerprint)?));
|
|
| 169 |
+ let private_key_pem = read_signing_resource( |
|
| 170 |
+ &private_key_path, |
|
| 171 |
+ "Read certificate private key", |
|
| 172 |
+ "The selected certificate has no Super Sideloader managed private key. Create a certificate or import its matching PEM key in Developer Settings.", |
|
| 173 |
+ )?; |
|
| 174 |
+ |
|
| 175 |
+ Ok(AppManagedSigningMaterial {
|
|
| 176 |
+ private_key_pem, |
|
| 177 |
+ certificate_der, |
|
| 178 |
+ }) |
|
| 150 | 179 |
} |
| 151 | 180 |
|
| 152 | 181 |
fn public_key_der_from_private_key(key_path: &Path) -> BackendResult<Vec<u8>> {
|
| ... | ... |
@@ -205,14 +234,60 @@ pub(crate) fn save_app_managed_private_key( |
| 205 | 234 |
path: keys_dir.clone(), |
| 206 | 235 |
source, |
| 207 | 236 |
})?; |
| 208 |
- let key_path = keys_dir.join(format!("{fingerprint}.pem"));
|
|
| 237 |
+ let key_path = keys_dir.join(format!("{}.pem", safe_fingerprint(fingerprint)?));
|
|
| 209 | 238 |
fs::write(&key_path, private_key_pem).map_err(|source| BackendError::Io {
|
| 210 | 239 |
action: "Save certificate private key", |
| 211 |
- path: key_path, |
|
| 240 |
+ path: key_path.clone(), |
|
| 212 | 241 |
source, |
| 242 |
+ })?; |
|
| 243 |
+ secure_private_key_file(&key_path) |
|
| 244 |
+} |
|
| 245 |
+ |
|
| 246 |
+#[cfg(unix)] |
|
| 247 |
+fn secure_private_key_file(path: &Path) -> BackendResult<()> {
|
|
| 248 |
+ fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|source| {
|
|
| 249 |
+ BackendError::Io {
|
|
| 250 |
+ action: "Secure certificate private key", |
|
| 251 |
+ path: path.to_path_buf(), |
|
| 252 |
+ source, |
|
| 253 |
+ } |
|
| 254 |
+ }) |
|
| 255 |
+} |
|
| 256 |
+ |
|
| 257 |
+#[cfg(not(unix))] |
|
| 258 |
+fn secure_private_key_file(_: &Path) -> BackendResult<()> {
|
|
| 259 |
+ Ok(()) |
|
| 260 |
+} |
|
| 261 |
+ |
|
| 262 |
+fn read_signing_resource( |
|
| 263 |
+ path: &Path, |
|
| 264 |
+ action: &'static str, |
|
| 265 |
+ missing_message: &str, |
|
| 266 |
+) -> BackendResult<Vec<u8>> {
|
|
| 267 |
+ fs::read(path).map_err(|source| {
|
|
| 268 |
+ if source.kind() == std::io::ErrorKind::NotFound {
|
|
| 269 |
+ BackendError::Message(missing_message.to_string()) |
|
| 270 |
+ } else {
|
|
| 271 |
+ BackendError::Io {
|
|
| 272 |
+ action, |
|
| 273 |
+ path: path.to_path_buf(), |
|
| 274 |
+ source, |
|
| 275 |
+ } |
|
| 276 |
+ } |
|
| 213 | 277 |
}) |
| 214 | 278 |
} |
| 215 | 279 |
|
| 280 |
+fn safe_fingerprint(fingerprint: &str) -> BackendResult<String> {
|
|
| 281 |
+ let fingerprint = fingerprint.trim().to_ascii_uppercase(); |
|
| 282 |
+ if fingerprint.len() == 40 && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
|
| 283 |
+ Ok(fingerprint) |
|
| 284 |
+ } else {
|
|
| 285 |
+ Err(BackendError::Cache( |
|
| 286 |
+ "Signing certificate cache contains an invalid fingerprint.".to_string(), |
|
| 287 |
+ )) |
|
| 288 |
+ } |
|
| 289 |
+} |
|
| 290 |
+ |
|
| 216 | 291 |
fn certificate_keys_dir() -> BackendResult<PathBuf> {
|
| 217 | 292 |
app_data_dir() |
| 218 | 293 |
.map(|path| path.join("certificates").join("keys"))
|
| ... | ... |
@@ -221,6 +296,14 @@ fn certificate_keys_dir() -> BackendResult<PathBuf> {
|
| 221 | 296 |
}) |
| 222 | 297 |
} |
| 223 | 298 |
|
| 299 |
+fn signing_certificates_dir() -> BackendResult<PathBuf> {
|
|
| 300 |
+ app_data_dir() |
|
| 301 |
+ .map(|path| path.join("certificates").join("certificates"))
|
|
| 302 |
+ .ok_or_else(|| {
|
|
| 303 |
+ BackendError::Unsupported("The application data folder is not available.".to_string())
|
|
| 304 |
+ }) |
|
| 305 |
+} |
|
| 306 |
+ |
|
| 224 | 307 |
fn certificate_temp_dir() -> BackendResult<PathBuf> {
|
| 225 | 308 |
app_data_dir() |
| 226 | 309 |
.map(|path| path.join("certificates").join("tmp"))
|
| ... | ... |
@@ -1,12 +1,43 @@ |
| 1 | 1 |
use crate::backend::{BackendError, BackendResult};
|
| 2 | 2 |
use grandslam::{AuthToken, Token};
|
| 3 | 3 |
use serde::{Deserialize, Serialize};
|
| 4 |
+use std::collections::HashMap; |
|
| 5 |
+use std::sync::{Mutex, MutexGuard, OnceLock};
|
|
| 4 | 6 |
use std::time::{SystemTime, UNIX_EPOCH};
|
| 5 | 7 |
|
| 6 | 8 |
const KEYCHAIN_SESSION_VERSION: u32 = 1; |
| 7 | 9 |
const KEYRING_SERVICE: &str = "com.Dadoum.Super-Sideloader"; |
| 8 | 10 |
const TOKEN_EXPIRY_GRACE_MILLIS: u64 = 5 * 60 * 1000; |
| 9 | 11 |
|
| 12 |
+type KeychainResult<T> = Result<T, String>; |
|
| 13 |
+type SessionCache = HashMap<String, CachedKeychainLookup>; |
|
| 14 |
+type SessionCacheGuard = MutexGuard<'static, SessionCache>; |
|
| 15 |
+ |
|
| 16 |
+static SESSION_CACHE: OnceLock<Mutex<SessionCache>> = OnceLock::new(); |
|
| 17 |
+ |
|
| 18 |
+#[derive(Clone, Debug)] |
|
| 19 |
+struct CachedKeychainSession {
|
|
| 20 |
+ encoded_contents: String, |
|
| 21 |
+ session: DeveloperAccountKeychainSession, |
|
| 22 |
+} |
|
| 23 |
+ |
|
| 24 |
+#[derive(Clone, Debug)] |
|
| 25 |
+enum CachedKeychainLookup {
|
|
| 26 |
+ Session(CachedKeychainSession), |
|
| 27 |
+ Missing, |
|
| 28 |
+ Failed(String), |
|
| 29 |
+} |
|
| 30 |
+ |
|
| 31 |
+impl CachedKeychainLookup {
|
|
| 32 |
+ fn result(&self) -> BackendResult<Option<DeveloperAccountKeychainSession>> {
|
|
| 33 |
+ match self {
|
|
| 34 |
+ Self::Session(cached) => Ok(Some(cached.session.clone())), |
|
| 35 |
+ Self::Missing => Ok(None), |
|
| 36 |
+ Self::Failed(error) => Err(BackendError::Keychain(error.clone())), |
|
| 37 |
+ } |
|
| 38 |
+ } |
|
| 39 |
+} |
|
| 40 |
+ |
|
| 10 | 41 |
#[derive(Clone, Debug, Deserialize, Serialize)] |
| 11 | 42 |
pub(crate) struct DeveloperAccountKeychainSession {
|
| 12 | 43 |
pub(crate) version: u32, |
| ... | ... |
@@ -34,44 +65,83 @@ pub(crate) fn save_keychain_session( |
| 34 | 65 |
account_id: &str, |
| 35 | 66 |
session: &DeveloperAccountKeychainSession, |
| 36 | 67 |
) -> BackendResult<()> {
|
| 37 |
- let contents = toml::to_string(session).map_err(|error| {
|
|
| 38 |
- BackendError::Keychain(format!( |
|
| 39 |
- "Failed to encode account keychain payload: {error}"
|
|
| 40 |
- )) |
|
| 41 |
- })?; |
|
| 42 |
- keyring_entry(account_id)? |
|
| 68 |
+ let contents = encode_keychain_session(session).map_err(BackendError::Keychain)?; |
|
| 69 |
+ let mut cache = lock_session_cache()?; |
|
| 70 |
+ if matches!( |
|
| 71 |
+ cache.get(account_id), |
|
| 72 |
+ Some(CachedKeychainLookup::Session(cached)) |
|
| 73 |
+ if cached.encoded_contents == contents |
|
| 74 |
+ ) {
|
|
| 75 |
+ return Ok(()); |
|
| 76 |
+ } |
|
| 77 |
+ |
|
| 78 |
+ keyring_entry(account_id) |
|
| 79 |
+ .map_err(BackendError::Keychain)? |
|
| 43 | 80 |
.set_password(&contents) |
| 44 | 81 |
.map_err(|error| {
|
| 45 | 82 |
BackendError::Keychain(format!( |
| 46 | 83 |
"Failed to save account session to the system keychain: {error}"
|
| 47 | 84 |
)) |
| 48 |
- }) |
|
| 85 |
+ })?; |
|
| 86 |
+ cache_keychain_session_locked(&mut cache, account_id, session.clone(), contents); |
|
| 87 |
+ Ok(()) |
|
| 49 | 88 |
} |
| 50 | 89 |
|
| 51 | 90 |
pub(crate) fn load_keychain_session( |
| 52 | 91 |
account_id: &str, |
| 53 | 92 |
) -> BackendResult<Option<DeveloperAccountKeychainSession>> {
|
| 54 |
- let contents = match keyring_entry(account_id)?.get_password() {
|
|
| 55 |
- Ok(contents) => contents, |
|
| 56 |
- Err(keyring::Error::NoEntry) => return Ok(None), |
|
| 57 |
- Err(error) => {
|
|
| 58 |
- return Err(BackendError::Keychain(format!( |
|
| 93 |
+ load_keychain_session_once(account_id, || {
|
|
| 94 |
+ let entry = keyring_entry(account_id)?; |
|
| 95 |
+ match entry.get_password() {
|
|
| 96 |
+ Ok(contents) => Ok(Some(contents)), |
|
| 97 |
+ Err(keyring::Error::NoEntry) => Ok(None), |
|
| 98 |
+ Err(error) => Err(format!( |
|
| 59 | 99 |
"Failed to read account session from the system keychain: {error}"
|
| 60 |
- ))); |
|
| 100 |
+ )), |
|
| 101 |
+ } |
|
| 102 |
+ }) |
|
| 61 | 103 |
} |
| 104 |
+ |
|
| 105 |
+fn load_keychain_session_once( |
|
| 106 |
+ account_id: &str, |
|
| 107 |
+ read: impl FnOnce() -> KeychainResult<Option<String>>, |
|
| 108 |
+) -> BackendResult<Option<DeveloperAccountKeychainSession>> {
|
|
| 109 |
+ let mut cache = lock_session_cache()?; |
|
| 110 |
+ if let Some(cached) = cache.get(account_id) {
|
|
| 111 |
+ return cached.result(); |
|
| 112 |
+ } |
|
| 113 |
+ |
|
| 114 |
+ let lookup = match read() {
|
|
| 115 |
+ Ok(Some(contents)) => match decode_keychain_session(&contents) {
|
|
| 116 |
+ Ok(session) => CachedKeychainLookup::Session(session), |
|
| 117 |
+ Err(error) => CachedKeychainLookup::Failed(error), |
|
| 118 |
+ }, |
|
| 119 |
+ Ok(None) => CachedKeychainLookup::Missing, |
|
| 120 |
+ Err(error) => CachedKeychainLookup::Failed(error), |
|
| 62 | 121 |
}; |
| 63 |
- let mut session: DeveloperAccountKeychainSession = |
|
| 64 |
- toml::from_str(&contents).map_err(|error| {
|
|
| 65 |
- BackendError::Keychain(format!( |
|
| 66 |
- "Failed to decode account keychain payload: {error}"
|
|
| 67 |
- )) |
|
| 68 |
- })?; |
|
| 69 |
- session.version = KEYCHAIN_SESSION_VERSION; |
|
| 70 |
- Ok(Some(session)) |
|
| 122 |
+ let result = lookup.result(); |
|
| 123 |
+ cache.insert(account_id.to_string(), lookup); |
|
| 124 |
+ result |
|
| 125 |
+} |
|
| 126 |
+ |
|
| 127 |
+pub(crate) fn cache_keychain_session( |
|
| 128 |
+ account_id: &str, |
|
| 129 |
+ session: &DeveloperAccountKeychainSession, |
|
| 130 |
+) -> BackendResult<()> {
|
|
| 131 |
+ let contents = encode_keychain_session(session).map_err(BackendError::Keychain)?; |
|
| 132 |
+ let mut cache = lock_session_cache()?; |
|
| 133 |
+ cache_keychain_session_locked(&mut cache, account_id, session.clone(), contents); |
|
| 134 |
+ Ok(()) |
|
| 71 | 135 |
} |
| 72 | 136 |
|
| 73 | 137 |
pub(crate) fn delete_keychain_session(account_id: &str) -> BackendResult<()> {
|
| 74 |
- match keyring_entry(account_id)?.delete_credential() {
|
|
| 138 |
+ let mut cache = lock_session_cache()?; |
|
| 139 |
+ cache.remove(account_id); |
|
| 140 |
+ |
|
| 141 |
+ match keyring_entry(account_id) |
|
| 142 |
+ .map_err(BackendError::Keychain)? |
|
| 143 |
+ .delete_credential() |
|
| 144 |
+ {
|
|
| 75 | 145 |
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), |
| 76 | 146 |
Err(error) => Err(BackendError::Keychain(format!( |
| 77 | 147 |
"Failed to remove account session from the system keychain: {error}"
|
| ... | ... |
@@ -103,15 +173,86 @@ pub(crate) fn current_epoch_millis() -> u64 {
|
| 103 | 173 |
.unwrap_or(0) |
| 104 | 174 |
} |
| 105 | 175 |
|
| 106 |
-fn keyring_entry(account_id: &str) -> BackendResult<keyring::Entry> {
|
|
| 107 |
- keyring::Entry::new(keyring_service(), &keyring_account_name(account_id)).map_err(|error| {
|
|
| 108 |
- BackendError::Keychain(format!("Failed to access the system keychain: {error}"))
|
|
| 176 |
+fn keyring_entry(account_id: &str) -> KeychainResult<keyring::Entry> {
|
|
| 177 |
+ keyring::Entry::new(keyring_service(), &keyring_account_name(account_id)) |
|
| 178 |
+ .map_err(|error| format!("Failed to access the system keychain: {error}"))
|
|
| 179 |
+} |
|
| 180 |
+ |
|
| 181 |
+fn encode_keychain_session(session: &DeveloperAccountKeychainSession) -> KeychainResult<String> {
|
|
| 182 |
+ toml::to_string(session) |
|
| 183 |
+ .map_err(|error| format!("Failed to encode account keychain payload: {error}"))
|
|
| 184 |
+} |
|
| 185 |
+ |
|
| 186 |
+fn decode_keychain_session(contents: &str) -> KeychainResult<CachedKeychainSession> {
|
|
| 187 |
+ let mut session: DeveloperAccountKeychainSession = toml::from_str(contents) |
|
| 188 |
+ .map_err(|error| format!("Failed to decode account keychain payload: {error}"))?;
|
|
| 189 |
+ session.version = KEYCHAIN_SESSION_VERSION; |
|
| 190 |
+ let encoded_contents = encode_keychain_session(&session)?; |
|
| 191 |
+ Ok(CachedKeychainSession {
|
|
| 192 |
+ encoded_contents, |
|
| 193 |
+ session, |
|
| 109 | 194 |
}) |
| 110 | 195 |
} |
| 111 | 196 |
|
| 197 |
+fn session_cache() -> &'static Mutex<SessionCache> {
|
|
| 198 |
+ SESSION_CACHE.get_or_init(|| Mutex::new(HashMap::new())) |
|
| 199 |
+} |
|
| 200 |
+ |
|
| 201 |
+fn lock_session_cache() -> BackendResult<SessionCacheGuard> {
|
|
| 202 |
+ session_cache().lock().map_err(|_| {
|
|
| 203 |
+ BackendError::Keychain("Failed to lock account keychain session cache.".to_string())
|
|
| 204 |
+ }) |
|
| 205 |
+} |
|
| 206 |
+ |
|
| 207 |
+fn cache_keychain_session_locked( |
|
| 208 |
+ cache: &mut SessionCache, |
|
| 209 |
+ account_id: &str, |
|
| 210 |
+ session: DeveloperAccountKeychainSession, |
|
| 211 |
+ encoded_contents: String, |
|
| 212 |
+) {
|
|
| 213 |
+ cache.insert( |
|
| 214 |
+ account_id.to_string(), |
|
| 215 |
+ CachedKeychainLookup::Session(CachedKeychainSession {
|
|
| 216 |
+ encoded_contents, |
|
| 217 |
+ session, |
|
| 218 |
+ }), |
|
| 219 |
+ ); |
|
| 220 |
+} |
|
| 221 |
+ |
|
| 112 | 222 |
#[cfg(test)] |
| 113 | 223 |
mod tests {
|
| 114 | 224 |
use super::*; |
| 225 |
+ use std::cell::Cell; |
|
| 226 |
+ use std::sync::atomic::{AtomicUsize, Ordering};
|
|
| 227 |
+ use std::sync::{Arc, Barrier};
|
|
| 228 |
+ use std::thread; |
|
| 229 |
+ use std::time::Duration; |
|
| 230 |
+ |
|
| 231 |
+ fn test_account_id(label: &str) -> String {
|
|
| 232 |
+ format!("{label}-{}-{}", std::process::id(), current_epoch_millis())
|
|
| 233 |
+ } |
|
| 234 |
+ |
|
| 235 |
+ fn test_session() -> DeveloperAccountKeychainSession {
|
|
| 236 |
+ DeveloperAccountKeychainSession::new( |
|
| 237 |
+ AuthToken {
|
|
| 238 |
+ alt_dsid: "alt-dsid".to_string(), |
|
| 239 |
+ idms_token: "idms-token".to_string(), |
|
| 240 |
+ session_key: vec![1, 2, 3], |
|
| 241 |
+ cookie: vec![4, 5, 6], |
|
| 242 |
+ identity_token: "identity-token".to_string(), |
|
| 243 |
+ }, |
|
| 244 |
+ Token {
|
|
| 245 |
+ duration: 3600, |
|
| 246 |
+ expiry_epoch_millis: current_epoch_millis() + 3_600_000, |
|
| 247 |
+ token: "heartbeat-token".to_string(), |
|
| 248 |
+ }, |
|
| 249 |
+ Token {
|
|
| 250 |
+ duration: 3600, |
|
| 251 |
+ expiry_epoch_millis: current_epoch_millis() + 3_600_000, |
|
| 252 |
+ token: "xcode-token".to_string(), |
|
| 253 |
+ }, |
|
| 254 |
+ ) |
|
| 255 |
+ } |
|
| 115 | 256 |
|
| 116 | 257 |
#[test] |
| 117 | 258 |
fn zero_expiry_is_treated_as_unknown_not_expired() {
|
| ... | ... |
@@ -122,4 +263,93 @@ mod tests {
|
| 122 | 263 |
fn near_expiry_is_detected() {
|
| 123 | 264 |
assert!(token_is_near_expiry(current_epoch_millis() + 1)); |
| 124 | 265 |
} |
| 266 |
+ |
|
| 267 |
+ #[test] |
|
| 268 |
+ fn concurrent_successful_lookups_read_the_keychain_once() {
|
|
| 269 |
+ let account_id = Arc::new(test_account_id("concurrent-session"));
|
|
| 270 |
+ let contents = Arc::new(encode_keychain_session(&test_session()).unwrap()); |
|
| 271 |
+ let reads = Arc::new(AtomicUsize::new(0)); |
|
| 272 |
+ let barrier = Arc::new(Barrier::new(2)); |
|
| 273 |
+ |
|
| 274 |
+ let handles = (0..2) |
|
| 275 |
+ .map(|_| {
|
|
| 276 |
+ let account_id = Arc::clone(&account_id); |
|
| 277 |
+ let contents = Arc::clone(&contents); |
|
| 278 |
+ let reads = Arc::clone(&reads); |
|
| 279 |
+ let barrier = Arc::clone(&barrier); |
|
| 280 |
+ thread::spawn(move || {
|
|
| 281 |
+ barrier.wait(); |
|
| 282 |
+ load_keychain_session_once(&account_id, || {
|
|
| 283 |
+ reads.fetch_add(1, Ordering::SeqCst); |
|
| 284 |
+ thread::sleep(Duration::from_millis(25)); |
|
| 285 |
+ Ok(Some(contents.as_ref().clone())) |
|
| 286 |
+ }) |
|
| 287 |
+ .unwrap() |
|
| 288 |
+ .unwrap() |
|
| 289 |
+ }) |
|
| 290 |
+ }) |
|
| 291 |
+ .collect::<Vec<_>>(); |
|
| 292 |
+ |
|
| 293 |
+ for handle in handles {
|
|
| 294 |
+ assert_eq!(handle.join().unwrap().auth_token.idms_token, "idms-token"); |
|
| 295 |
+ } |
|
| 296 |
+ assert_eq!(reads.load(Ordering::SeqCst), 1); |
|
| 297 |
+ } |
|
| 298 |
+ |
|
| 299 |
+ #[test] |
|
| 300 |
+ fn missing_keychain_session_is_memoized() {
|
|
| 301 |
+ let account_id = test_account_id("missing-session");
|
|
| 302 |
+ let reads = Cell::new(0); |
|
| 303 |
+ |
|
| 304 |
+ for _ in 0..2 {
|
|
| 305 |
+ let loaded = load_keychain_session_once(&account_id, || {
|
|
| 306 |
+ reads.set(reads.get() + 1); |
|
| 307 |
+ Ok(None) |
|
| 308 |
+ }) |
|
| 309 |
+ .unwrap(); |
|
| 310 |
+ assert!(loaded.is_none()); |
|
| 311 |
+ } |
|
| 312 |
+ |
|
| 313 |
+ assert_eq!(reads.get(), 1); |
|
| 314 |
+ } |
|
| 315 |
+ |
|
| 316 |
+ #[test] |
|
| 317 |
+ fn malformed_keychain_session_is_memoized() {
|
|
| 318 |
+ let account_id = test_account_id("malformed-session");
|
|
| 319 |
+ let reads = Cell::new(0); |
|
| 320 |
+ |
|
| 321 |
+ for _ in 0..2 {
|
|
| 322 |
+ let error = load_keychain_session_once(&account_id, || {
|
|
| 323 |
+ reads.set(reads.get() + 1); |
|
| 324 |
+ Ok(Some("not valid TOML".to_string()))
|
|
| 325 |
+ }) |
|
| 326 |
+ .unwrap_err(); |
|
| 327 |
+ assert!(error |
|
| 328 |
+ .user_message() |
|
| 329 |
+ .contains("decode account keychain payload"));
|
|
| 330 |
+ } |
|
| 331 |
+ |
|
| 332 |
+ assert_eq!(reads.get(), 1); |
|
| 333 |
+ } |
|
| 334 |
+ |
|
| 335 |
+ #[test] |
|
| 336 |
+ fn failed_keychain_lookup_is_memoized_and_can_be_replaced() {
|
|
| 337 |
+ let account_id = test_account_id("failed-session");
|
|
| 338 |
+ let reads = Cell::new(0); |
|
| 339 |
+ |
|
| 340 |
+ for _ in 0..2 {
|
|
| 341 |
+ let error = load_keychain_session_once(&account_id, || {
|
|
| 342 |
+ reads.set(reads.get() + 1); |
|
| 343 |
+ Err("Keychain access was denied.".to_string())
|
|
| 344 |
+ }) |
|
| 345 |
+ .unwrap_err(); |
|
| 346 |
+ assert!(error.user_message().contains("access was denied"));
|
|
| 347 |
+ } |
|
| 348 |
+ assert_eq!(reads.get(), 1); |
|
| 349 |
+ |
|
| 350 |
+ let session = test_session(); |
|
| 351 |
+ cache_keychain_session(&account_id, &session).unwrap(); |
|
| 352 |
+ let loaded = load_keychain_session(&account_id).unwrap().unwrap(); |
|
| 353 |
+ assert_eq!(loaded.auth_token.idms_token, session.auth_token.idms_token); |
|
| 354 |
+ } |
|
| 125 | 355 |
} |
| ... | ... |
@@ -7,17 +7,17 @@ use crate::backend::developer::cache::{
|
| 7 | 7 |
use crate::backend::developer::certificates::{
|
| 8 | 8 |
app_managed_private_key_fingerprints, certificate_fingerprint, |
| 9 | 9 |
certificate_public_key_fingerprint, generate_development_certificate_signing_request, |
| 10 |
- import_app_managed_private_key, local_code_signing_identity_fingerprints, |
|
| 11 |
- save_app_managed_private_key, GeneratedCertificateSigningRequest, |
|
| 10 |
+ import_app_managed_private_key, save_app_managed_certificate, save_app_managed_private_key, |
|
| 11 |
+ GeneratedCertificateSigningRequest, |
|
| 12 | 12 |
}; |
| 13 | 13 |
use crate::backend::developer::client::{with_developer_session, DeveloperSessionConfig};
|
| 14 | 14 |
use crate::backend::developer::keychain::{
|
| 15 |
- delete_keychain_session, load_keychain_session, save_keychain_session, token_is_near_expiry, |
|
| 16 |
- DeveloperAccountKeychainSession, |
|
| 15 |
+ cache_keychain_session, delete_keychain_session, load_keychain_session, save_keychain_session, |
|
| 16 |
+ token_is_near_expiry, DeveloperAccountKeychainSession, |
|
| 17 | 17 |
}; |
| 18 | 18 |
use crate::backend::runtime as backend_runtime; |
| 19 | 19 |
use crate::backend::{BackendError, BackendResult};
|
| 20 |
-use crate::domain::{AdiBackendKind, DeveloperAccount, MachineIdentity};
|
|
| 20 |
+use crate::domain::{AdiBackendKind, DeveloperAccount, DeveloperDevice, MachineIdentity};
|
|
| 21 | 21 |
use chrono::{DateTime, Local};
|
| 22 | 22 |
use grandslam::http_session::AnisetteHTTPSession; |
| 23 | 23 |
use grandslam::{AuthOutcome, AuthenticatedHTTPSession, Token};
|
| ... | ... |
@@ -25,11 +25,11 @@ use plist::{Dictionary, Value};
|
| 25 | 25 |
use std::path::PathBuf; |
| 26 | 26 |
use std::time::{Duration, UNIX_EPOCH};
|
| 27 | 27 |
use xcode::{
|
| 28 |
- AddAppIdAction, AppIdFeature, DeleteAppIdAction, DeveloperTeam, |
|
| 29 |
- DownloadTeamProvisioningProfileAction, IOSRequest, ListAllDevelopmentCertsAction, |
|
| 30 |
- ListAppIdsAction, ListTeamsAction, RevokeDevelopmentCertAction, SubmitDevelopmentCsrAction, |
|
| 31 |
- UpdateAppIdAction, ViewDeveloperAction, XcodeSession, XCODE_BUNDLE_INFORMATION, |
|
| 32 |
- XCODE_TOKEN_IDENTIFIER, |
|
| 28 |
+ AddAppIdAction, AddDeviceAction, AppIdFeature, DeleteAppIdAction, DeleteDeviceAction, |
|
| 29 |
+ DeveloperTeam, DownloadTeamProvisioningProfileAction, IOSRequest, |
|
| 30 |
+ ListAllDevelopmentCertsAction, ListAppIdsAction, ListDevicesAction, ListTeamsAction, |
|
| 31 |
+ RevokeDevelopmentCertAction, SubmitDevelopmentCsrAction, UpdateAppIdAction, |
|
| 32 |
+ ViewDeveloperAction, XcodeSession, XCODE_BUNDLE_INFORMATION, XCODE_TOKEN_IDENTIFIER, |
|
| 33 | 33 |
}; |
| 34 | 34 |
|
| 35 | 35 |
const HEARTBEAT_TOKEN_IDENTIFIER: &str = "com.apple.gs.idms.hb"; |
| ... | ... |
@@ -139,6 +139,39 @@ pub(crate) struct DeveloperProvisioningProfileDownloadRequest {
|
| 139 | 139 |
pub(crate) android_adi_identifier: String, |
| 140 | 140 |
} |
| 141 | 141 |
|
| 142 |
+#[derive(Clone, Debug)] |
|
| 143 |
+pub(crate) struct DeveloperDeviceListRequest {
|
|
| 144 |
+ pub(crate) account_id: String, |
|
| 145 |
+ pub(crate) email: String, |
|
| 146 |
+ pub(crate) team_id: String, |
|
| 147 |
+ pub(crate) adi_backend: AdiBackendKind, |
|
| 148 |
+ pub(crate) machine_identity: MachineIdentity, |
|
| 149 |
+ pub(crate) android_adi_identifier: String, |
|
| 150 |
+} |
|
| 151 |
+ |
|
| 152 |
+#[derive(Clone, Debug)] |
|
| 153 |
+pub(crate) struct DeveloperDeviceAddRequest {
|
|
| 154 |
+ pub(crate) account_id: String, |
|
| 155 |
+ pub(crate) email: String, |
|
| 156 |
+ pub(crate) team_id: String, |
|
| 157 |
+ pub(crate) name: String, |
|
| 158 |
+ pub(crate) udid: String, |
|
| 159 |
+ pub(crate) adi_backend: AdiBackendKind, |
|
| 160 |
+ pub(crate) machine_identity: MachineIdentity, |
|
| 161 |
+ pub(crate) android_adi_identifier: String, |
|
| 162 |
+} |
|
| 163 |
+ |
|
| 164 |
+#[derive(Clone, Debug)] |
|
| 165 |
+pub(crate) struct DeveloperDeviceDeleteRequest {
|
|
| 166 |
+ pub(crate) account_id: String, |
|
| 167 |
+ pub(crate) email: String, |
|
| 168 |
+ pub(crate) team_id: String, |
|
| 169 |
+ pub(crate) device_id: String, |
|
| 170 |
+ pub(crate) adi_backend: AdiBackendKind, |
|
| 171 |
+ pub(crate) machine_identity: MachineIdentity, |
|
| 172 |
+ pub(crate) android_adi_identifier: String, |
|
| 173 |
+} |
|
| 174 |
+ |
|
| 142 | 175 |
#[derive(Clone, Debug)] |
| 143 | 176 |
pub(crate) struct DownloadedProvisioningProfile {
|
| 144 | 177 |
pub(crate) name: String, |
| ... | ... |
@@ -350,6 +383,70 @@ pub(crate) async fn download_developer_provisioning_profile( |
| 350 | 383 |
.await? |
| 351 | 384 |
} |
| 352 | 385 |
|
| 386 |
+pub(crate) async fn list_developer_devices( |
|
| 387 |
+ request: DeveloperDeviceListRequest, |
|
| 388 |
+) -> BackendResult<Vec<DeveloperDevice>> {
|
|
| 389 |
+ let account = refresh_account_seed(&request.account_id, &request.email); |
|
| 390 |
+ let session = validated_keychain_session(&account)?; |
|
| 391 |
+ |
|
| 392 |
+ backend_runtime::run("developer device list", move || async move {
|
|
| 393 |
+ let account_id = account.id.clone(); |
|
| 394 |
+ let result = with_developer_session( |
|
| 395 |
+ session_config( |
|
| 396 |
+ request.adi_backend, |
|
| 397 |
+ request.machine_identity, |
|
| 398 |
+ request.android_adi_identifier, |
|
| 399 |
+ ), |
|
| 400 |
+ session, |
|
| 401 |
+ |xcode_session| {
|
|
| 402 |
+ Box::pin( |
|
| 403 |
+ async move { fetch_developer_devices(xcode_session, &request.team_id).await },
|
|
| 404 |
+ ) |
|
| 405 |
+ }, |
|
| 406 |
+ ) |
|
| 407 |
+ .await?; |
|
| 408 |
+ |
|
| 409 |
+ save_keychain_session(&account_id, &result.keychain_session)?; |
|
| 410 |
+ Ok(result.value) |
|
| 411 |
+ }) |
|
| 412 |
+ .await? |
|
| 413 |
+} |
|
| 414 |
+ |
|
| 415 |
+pub(crate) async fn add_developer_device( |
|
| 416 |
+ request: DeveloperDeviceAddRequest, |
|
| 417 |
+) -> BackendResult<Vec<DeveloperDevice>> {
|
|
| 418 |
+ mutate_developer_devices( |
|
| 419 |
+ request.account_id, |
|
| 420 |
+ request.email, |
|
| 421 |
+ request.adi_backend, |
|
| 422 |
+ request.machine_identity, |
|
| 423 |
+ request.android_adi_identifier, |
|
| 424 |
+ request.team_id, |
|
| 425 |
+ DeveloperDeviceOperation::Add {
|
|
| 426 |
+ name: request.name, |
|
| 427 |
+ udid: request.udid, |
|
| 428 |
+ }, |
|
| 429 |
+ ) |
|
| 430 |
+ .await |
|
| 431 |
+} |
|
| 432 |
+ |
|
| 433 |
+pub(crate) async fn delete_developer_device( |
|
| 434 |
+ request: DeveloperDeviceDeleteRequest, |
|
| 435 |
+) -> BackendResult<Vec<DeveloperDevice>> {
|
|
| 436 |
+ mutate_developer_devices( |
|
| 437 |
+ request.account_id, |
|
| 438 |
+ request.email, |
|
| 439 |
+ request.adi_backend, |
|
| 440 |
+ request.machine_identity, |
|
| 441 |
+ request.android_adi_identifier, |
|
| 442 |
+ request.team_id, |
|
| 443 |
+ DeveloperDeviceOperation::Delete {
|
|
| 444 |
+ device_id: request.device_id, |
|
| 445 |
+ }, |
|
| 446 |
+ ) |
|
| 447 |
+ .await |
|
| 448 |
+} |
|
| 449 |
+ |
|
| 353 | 450 |
pub(crate) async fn login_developer_account( |
| 354 | 451 |
request: DeveloperLoginRequest, |
| 355 | 452 |
) -> BackendResult<DeveloperLoginOutcome> {
|
| ... | ... |
@@ -392,6 +489,11 @@ enum DeveloperTeamOperation {
|
| 392 | 489 |
}, |
| 393 | 490 |
} |
| 394 | 491 |
|
| 492 |
+enum DeveloperDeviceOperation {
|
|
| 493 |
+ Add { name: String, udid: String },
|
|
| 494 |
+ Delete { device_id: String },
|
|
| 495 |
+} |
|
| 496 |
+ |
|
| 395 | 497 |
async fn mutate_team_resources( |
| 396 | 498 |
account_id: &str, |
| 397 | 499 |
email: &str, |
| ... | ... |
@@ -570,6 +672,129 @@ async fn perform_team_operation( |
| 570 | 672 |
} |
| 571 | 673 |
} |
| 572 | 674 |
|
| 675 |
+async fn mutate_developer_devices( |
|
| 676 |
+ account_id: String, |
|
| 677 |
+ email: String, |
|
| 678 |
+ adi_backend: AdiBackendKind, |
|
| 679 |
+ machine_identity: MachineIdentity, |
|
| 680 |
+ android_adi_identifier: String, |
|
| 681 |
+ team_id: String, |
|
| 682 |
+ operation: DeveloperDeviceOperation, |
|
| 683 |
+) -> BackendResult<Vec<DeveloperDevice>> {
|
|
| 684 |
+ let account = refresh_account_seed(&account_id, &email); |
|
| 685 |
+ let session = validated_keychain_session(&account)?; |
|
| 686 |
+ |
|
| 687 |
+ backend_runtime::run("developer device update", move || async move {
|
|
| 688 |
+ let account_id = account.id.clone(); |
|
| 689 |
+ let result = with_developer_session( |
|
| 690 |
+ session_config(adi_backend, machine_identity, android_adi_identifier), |
|
| 691 |
+ session, |
|
| 692 |
+ |xcode_session| {
|
|
| 693 |
+ Box::pin(async move {
|
|
| 694 |
+ perform_device_operation(xcode_session, &team_id, operation).await?; |
|
| 695 |
+ fetch_developer_devices(xcode_session, &team_id).await |
|
| 696 |
+ }) |
|
| 697 |
+ }, |
|
| 698 |
+ ) |
|
| 699 |
+ .await?; |
|
| 700 |
+ |
|
| 701 |
+ save_keychain_session(&account_id, &result.keychain_session)?; |
|
| 702 |
+ Ok(result.value) |
|
| 703 |
+ }) |
|
| 704 |
+ .await? |
|
| 705 |
+} |
|
| 706 |
+ |
|
| 707 |
+async fn perform_device_operation( |
|
| 708 |
+ xcode_session: &XcodeSession<'_, '_>, |
|
| 709 |
+ team_id: &str, |
|
| 710 |
+ operation: DeveloperDeviceOperation, |
|
| 711 |
+) -> BackendResult<()> {
|
|
| 712 |
+ match operation {
|
|
| 713 |
+ DeveloperDeviceOperation::Add { name, udid } => {
|
|
| 714 |
+ if name.trim().is_empty() {
|
|
| 715 |
+ return Err(BackendError::Message( |
|
| 716 |
+ "Enter a device name before registering it.".to_string(), |
|
| 717 |
+ )); |
|
| 718 |
+ } |
|
| 719 |
+ if udid.trim().is_empty() {
|
|
| 720 |
+ return Err(BackendError::Message( |
|
| 721 |
+ "Enter a device UDID before registering it.".to_string(), |
|
| 722 |
+ )); |
|
| 723 |
+ } |
|
| 724 |
+ xcode_session |
|
| 725 |
+ .perform_developer_action(IOSRequest::new(AddDeviceAction {
|
|
| 726 |
+ team_id: team_id.into(), |
|
| 727 |
+ name, |
|
| 728 |
+ device_number: udid, |
|
| 729 |
+ })) |
|
| 730 |
+ .await |
|
| 731 |
+ .map_err(|error| {
|
|
| 732 |
+ BackendError::Network(format!("Failed to register device: {error}"))
|
|
| 733 |
+ })? |
|
| 734 |
+ .map_err(|error| {
|
|
| 735 |
+ BackendError::AppleAuth(format!( |
|
| 736 |
+ "Failed to parse device registration response: {error}"
|
|
| 737 |
+ )) |
|
| 738 |
+ })?; |
|
| 739 |
+ } |
|
| 740 |
+ DeveloperDeviceOperation::Delete { device_id } => {
|
|
| 741 |
+ xcode_session |
|
| 742 |
+ .perform_developer_action(IOSRequest::new(DeleteDeviceAction {
|
|
| 743 |
+ team_id: team_id.into(), |
|
| 744 |
+ device_id, |
|
| 745 |
+ })) |
|
| 746 |
+ .await |
|
| 747 |
+ .map_err(|error| {
|
|
| 748 |
+ BackendError::Network(format!("Failed to remove device: {error}"))
|
|
| 749 |
+ })? |
|
| 750 |
+ .map_err(|error| {
|
|
| 751 |
+ BackendError::AppleAuth(format!( |
|
| 752 |
+ "Failed to parse device removal response: {error}"
|
|
| 753 |
+ )) |
|
| 754 |
+ })?; |
|
| 755 |
+ } |
|
| 756 |
+ } |
|
| 757 |
+ Ok(()) |
|
| 758 |
+} |
|
| 759 |
+ |
|
| 760 |
+async fn fetch_developer_devices( |
|
| 761 |
+ xcode_session: &XcodeSession<'_, '_>, |
|
| 762 |
+ team_id: &str, |
|
| 763 |
+) -> BackendResult<Vec<DeveloperDevice>> {
|
|
| 764 |
+ let mut devices = xcode_session |
|
| 765 |
+ .perform_developer_action(IOSRequest::new(ListDevicesAction {
|
|
| 766 |
+ team_id: team_id.into(), |
|
| 767 |
+ })) |
|
| 768 |
+ .await |
|
| 769 |
+ .map_err(|error| {
|
|
| 770 |
+ BackendError::Network(format!("Failed to fetch registered devices: {error}"))
|
|
| 771 |
+ })? |
|
| 772 |
+ .map_err(|error| {
|
|
| 773 |
+ BackendError::AppleAuth(format!( |
|
| 774 |
+ "Failed to parse registered devices response: {error}"
|
|
| 775 |
+ )) |
|
| 776 |
+ })? |
|
| 777 |
+ .devices |
|
| 778 |
+ .into_iter() |
|
| 779 |
+ .map(|device| DeveloperDevice {
|
|
| 780 |
+ id: device.device_id, |
|
| 781 |
+ name: device.name, |
|
| 782 |
+ udid: device.device_number, |
|
| 783 |
+ }) |
|
| 784 |
+ .collect::<Vec<_>>(); |
|
| 785 |
+ sort_developer_devices(&mut devices); |
|
| 786 |
+ Ok(devices) |
|
| 787 |
+} |
|
| 788 |
+ |
|
| 789 |
+fn sort_developer_devices(devices: &mut [DeveloperDevice]) {
|
|
| 790 |
+ devices.sort_by(|left, right| {
|
|
| 791 |
+ left.name |
|
| 792 |
+ .to_ascii_lowercase() |
|
| 793 |
+ .cmp(&right.name.to_ascii_lowercase()) |
|
| 794 |
+ .then_with(|| left.udid.cmp(&right.udid)) |
|
| 795 |
+ }); |
|
| 796 |
+} |
|
| 797 |
+ |
|
| 573 | 798 |
async fn refresh_cached_team_only( |
| 574 | 799 |
account_id: &str, |
| 575 | 800 |
email: &str, |
| ... | ... |
@@ -762,6 +987,8 @@ async fn login_developer_account_async( |
| 762 | 987 |
} |
| 763 | 988 |
return Err(error); |
| 764 | 989 |
} |
| 990 |
+ } else {
|
|
| 991 |
+ cache_keychain_session(&account_cache.id, &keychain_session)?; |
|
| 765 | 992 |
} |
| 766 | 993 |
|
| 767 | 994 |
Ok(DeveloperLoginOutcome::SignedIn(account_cache.into())) |
| ... | ... |
@@ -847,7 +1074,6 @@ async fn fetch_team_resources( |
| 847 | 1074 |
team: CachedDeveloperTeam, |
| 848 | 1075 |
xcode_session: &XcodeSession<'_, '_>, |
| 849 | 1076 |
) -> BackendResult<CachedDeveloperTeam> {
|
| 850 |
- let local_identity_fingerprints = local_code_signing_identity_fingerprints(); |
|
| 851 | 1077 |
let app_managed_key_fingerprints = app_managed_private_key_fingerprints(); |
| 852 | 1078 |
let certificates = xcode_session |
| 853 | 1079 |
.perform_developer_action(IOSRequest::new(ListAllDevelopmentCertsAction {
|
| ... | ... |
@@ -868,27 +1094,26 @@ async fn fetch_team_resources( |
| 868 | 1094 |
})? |
| 869 | 1095 |
.certificates |
| 870 | 1096 |
.into_iter() |
| 871 |
- .map(|certificate| {
|
|
| 1097 |
+ .map(|certificate| -> BackendResult<_> {
|
|
| 872 | 1098 |
let fingerprint = certificate_fingerprint(&certificate.cert_content); |
| 873 | 1099 |
let public_key_fingerprint = |
| 874 | 1100 |
certificate_public_key_fingerprint(&certificate.cert_content); |
| 875 |
- CachedDevelopmentCertificate {
|
|
| 1101 |
+ save_app_managed_certificate(&fingerprint, &certificate.cert_content)?; |
|
| 1102 |
+ Ok(CachedDevelopmentCertificate {
|
|
| 876 | 1103 |
id: certificate.certificate_id, |
| 877 | 1104 |
name: certificate.name, |
| 878 | 1105 |
serial_number: certificate.serial_number, |
| 879 | 1106 |
machine_name: certificate.machine_name, |
| 880 |
- private_key_available: local_identity_fingerprints |
|
| 881 |
- .iter() |
|
| 882 |
- .any(|identity| identity == &fingerprint) |
|
| 883 |
- || public_key_fingerprint.as_ref().is_some_and(|fingerprint| {
|
|
| 1107 |
+ private_key_available: public_key_fingerprint.as_ref().is_some_and(|fingerprint| {
|
|
| 884 | 1108 |
app_managed_key_fingerprints |
| 885 | 1109 |
.iter() |
| 886 | 1110 |
.any(|candidate| candidate == fingerprint) |
| 887 | 1111 |
}), |
| 1112 |
+ certificate_fingerprint: Some(fingerprint), |
|
| 888 | 1113 |
public_key_fingerprint, |
| 889 |
- } |
|
| 890 | 1114 |
}) |
| 891 |
- .collect(); |
|
| 1115 |
+ }) |
|
| 1116 |
+ .collect::<BackendResult<Vec<_>>>()?; |
|
| 892 | 1117 |
|
| 893 | 1118 |
let app_id_response = xcode_session |
| 894 | 1119 |
.perform_developer_action(IOSRequest::new(ListAppIdsAction {
|
| ... | ... |
@@ -1188,4 +1413,35 @@ mod tests {
|
| 1188 | 1413 |
assert_eq!(seed.id, "TEAM"); |
| 1189 | 1414 |
assert_eq!(seed.role, "Individual"); |
| 1190 | 1415 |
} |
| 1416 |
+ |
|
| 1417 |
+ #[test] |
|
| 1418 |
+ fn developer_devices_are_sorted_by_name_then_udid() {
|
|
| 1419 |
+ let mut devices = vec![ |
|
| 1420 |
+ DeveloperDevice {
|
|
| 1421 |
+ id: "3".into(), |
|
| 1422 |
+ name: "iPhone".into(), |
|
| 1423 |
+ udid: "B".into(), |
|
| 1424 |
+ }, |
|
| 1425 |
+ DeveloperDevice {
|
|
| 1426 |
+ id: "1".into(), |
|
| 1427 |
+ name: "iPad".into(), |
|
| 1428 |
+ udid: "Z".into(), |
|
| 1429 |
+ }, |
|
| 1430 |
+ DeveloperDevice {
|
|
| 1431 |
+ id: "2".into(), |
|
| 1432 |
+ name: "iphone".into(), |
|
| 1433 |
+ udid: "A".into(), |
|
| 1434 |
+ }, |
|
| 1435 |
+ ]; |
|
| 1436 |
+ |
|
| 1437 |
+ sort_developer_devices(&mut devices); |
|
| 1438 |
+ |
|
| 1439 |
+ assert_eq!( |
|
| 1440 |
+ devices |
|
| 1441 |
+ .into_iter() |
|
| 1442 |
+ .map(|device| device.id) |
|
| 1443 |
+ .collect::<Vec<_>>(), |
|
| 1444 |
+ vec!["1", "2", "3"] |
|
| 1445 |
+ ); |
|
| 1446 |
+ } |
|
| 1191 | 1447 |
} |
| ... | ... |
@@ -1,6 +1,7 @@ |
| 1 | 1 |
use crate::backend::developer::accounts; |
| 2 |
+use crate::backend::developer::certificates::{self, AppManagedSigningMaterial};
|
|
| 2 | 3 |
use crate::backend::BackendResult; |
| 3 |
-use crate::domain::{AdiBackendKind, DeveloperAccount, MachineIdentity};
|
|
| 4 |
+use crate::domain::{AdiBackendKind, DeveloperAccount, DeveloperDevice, MachineIdentity};
|
|
| 4 | 5 |
use std::path::PathBuf; |
| 5 | 6 |
|
| 6 | 7 |
#[derive(Clone, Debug)] |
| ... | ... |
@@ -66,6 +67,13 @@ pub(crate) fn delete_account_cache(account_id: &str) -> BackendResult<()> {
|
| 66 | 67 |
accounts::delete_account_cache(account_id) |
| 67 | 68 |
} |
| 68 | 69 |
|
| 70 |
+pub(crate) fn load_signing_material( |
|
| 71 |
+ certificate_fingerprint: &str, |
|
| 72 |
+ public_key_fingerprint: &str, |
|
| 73 |
+) -> BackendResult<AppManagedSigningMaterial> {
|
|
| 74 |
+ certificates::load_app_managed_signing_material(certificate_fingerprint, public_key_fingerprint) |
|
| 75 |
+} |
|
| 76 |
+ |
|
| 69 | 77 |
pub(crate) async fn login(request: DeveloperLoginRequest) -> BackendResult<DeveloperLoginOutcome> {
|
| 70 | 78 |
accounts::login_developer_account(accounts::DeveloperLoginRequest {
|
| 71 | 79 |
email: request.email, |
| ... | ... |
@@ -154,6 +162,60 @@ pub(crate) async fn delete_app_id( |
| 154 | 162 |
.await |
| 155 | 163 |
} |
| 156 | 164 |
|
| 165 |
+pub(crate) async fn list_developer_devices( |
|
| 166 |
+ context: DeveloperSessionContext, |
|
| 167 |
+ team_id: String, |
|
| 168 |
+) -> BackendResult<Vec<DeveloperDevice>> {
|
|
| 169 |
+ let request = context.refresh_request(); |
|
| 170 |
+ accounts::list_developer_devices(accounts::DeveloperDeviceListRequest {
|
|
| 171 |
+ account_id: request.account_id, |
|
| 172 |
+ email: request.email, |
|
| 173 |
+ team_id, |
|
| 174 |
+ adi_backend: request.adi_backend, |
|
| 175 |
+ machine_identity: request.machine_identity, |
|
| 176 |
+ android_adi_identifier: request.android_adi_identifier, |
|
| 177 |
+ }) |
|
| 178 |
+ .await |
|
| 179 |
+} |
|
| 180 |
+ |
|
| 181 |
+pub(crate) async fn add_developer_device( |
|
| 182 |
+ context: DeveloperSessionContext, |
|
| 183 |
+ team_id: String, |
|
| 184 |
+ name: String, |
|
| 185 |
+ udid: String, |
|
| 186 |
+) -> BackendResult<Vec<DeveloperDevice>> {
|
|
| 187 |
+ let request = context.refresh_request(); |
|
| 188 |
+ accounts::add_developer_device(accounts::DeveloperDeviceAddRequest {
|
|
| 189 |
+ account_id: request.account_id, |
|
| 190 |
+ email: request.email, |
|
| 191 |
+ team_id, |
|
| 192 |
+ name, |
|
| 193 |
+ udid, |
|
| 194 |
+ adi_backend: request.adi_backend, |
|
| 195 |
+ machine_identity: request.machine_identity, |
|
| 196 |
+ android_adi_identifier: request.android_adi_identifier, |
|
| 197 |
+ }) |
|
| 198 |
+ .await |
|
| 199 |
+} |
|
| 200 |
+ |
|
| 201 |
+pub(crate) async fn delete_developer_device( |
|
| 202 |
+ context: DeveloperSessionContext, |
|
| 203 |
+ team_id: String, |
|
| 204 |
+ device_id: String, |
|
| 205 |
+) -> BackendResult<Vec<DeveloperDevice>> {
|
|
| 206 |
+ let request = context.refresh_request(); |
|
| 207 |
+ accounts::delete_developer_device(accounts::DeveloperDeviceDeleteRequest {
|
|
| 208 |
+ account_id: request.account_id, |
|
| 209 |
+ email: request.email, |
|
| 210 |
+ team_id, |
|
| 211 |
+ device_id, |
|
| 212 |
+ adi_backend: request.adi_backend, |
|
| 213 |
+ machine_identity: request.machine_identity, |
|
| 214 |
+ android_adi_identifier: request.android_adi_identifier, |
|
| 215 |
+ }) |
|
| 216 |
+ .await |
|
| 217 |
+} |
|
| 218 |
+ |
|
| 157 | 219 |
pub(crate) async fn create_certificate( |
| 158 | 220 |
context: DeveloperSessionContext, |
| 159 | 221 |
team_id: String, |
| ... | ... |
@@ -1,13 +1,22 @@ |
| 1 |
+use crate::backend::ipa::SignedAppBundle; |
|
| 1 | 2 |
use crate::backend::{runtime as backend_runtime, BackendError, BackendResult};
|
| 2 |
-use crate::domain::{Device, DeviceWatchEvent};
|
|
| 3 |
+use crate::domain::{Device, DeviceInstallProgress, DeviceWatchEvent};
|
|
| 3 | 4 |
use futures::{channel::mpsc::UnboundedSender, StreamExt};
|
| 4 | 5 |
use idevice::{
|
| 6 |
+ afc::{opcode::AfcFopenMode, AfcClient},
|
|
| 5 | 7 |
provider::UsbmuxdProvider, |
| 8 |
+ services::installation_proxy::InstallationProxyClient, |
|
| 6 | 9 |
services::lockdown::LockdownClient, |
| 7 | 10 |
usbmuxd::{Connection, UsbmuxdAddr, UsbmuxdDevice},
|
| 8 | 11 |
IdeviceService, |
| 9 | 12 |
}; |
| 10 | 13 |
use plist::Value; |
| 14 |
+use std::path::{Component, Path, PathBuf};
|
|
| 15 |
+use std::sync::{Arc, Mutex};
|
|
| 16 |
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
| 17 |
+use walkdir::WalkDir; |
|
| 18 |
+ |
|
| 19 |
+const APP_UPLOAD_BUFFER_SIZE: usize = 1024 * 1024; |
|
| 11 | 20 |
|
| 12 | 21 |
pub(crate) async fn discover_devices() -> BackendResult<Vec<Device>> {
|
| 13 | 22 |
backend_runtime::run_send("device discovery", discover_devices_async()).await?
|
| ... | ... |
@@ -19,6 +28,327 @@ pub(crate) async fn watch_device_changes( |
| 19 | 28 |
backend_runtime::run("device watcher", move || watch_device_changes_async(sender)).await?
|
| 20 | 29 |
} |
| 21 | 30 |
|
| 31 |
+pub(crate) async fn install_app( |
|
| 32 |
+ udid: String, |
|
| 33 |
+ signed_app: SignedAppBundle, |
|
| 34 |
+ progress: impl FnMut(DeviceInstallProgress) + Send + 'static, |
|
| 35 |
+) -> BackendResult<()> {
|
|
| 36 |
+ backend_runtime::run_send( |
|
| 37 |
+ "app installation", |
|
| 38 |
+ install_app_async(udid, signed_app, progress), |
|
| 39 |
+ ) |
|
| 40 |
+ .await? |
|
| 41 |
+} |
|
| 42 |
+ |
|
| 43 |
+async fn install_app_async( |
|
| 44 |
+ udid: String, |
|
| 45 |
+ signed_app: SignedAppBundle, |
|
| 46 |
+ progress: impl FnMut(DeviceInstallProgress) + Send + 'static, |
|
| 47 |
+) -> BackendResult<()> {
|
|
| 48 |
+ let progress = Arc::new(Mutex::new(progress)); |
|
| 49 |
+ report_install_progress(&progress, DeviceInstallProgress::Connecting); |
|
| 50 |
+ |
|
| 51 |
+ let provider = provider_for_udid(&udid).await?; |
|
| 52 |
+ let app_path = signed_app.path().to_path_buf(); |
|
| 53 |
+ let upload_plan = app_upload_plan(&app_path)?; |
|
| 54 |
+ |
|
| 55 |
+ let mut afc = AfcClient::connect(&provider).await.map_err(|error| {
|
|
| 56 |
+ BackendError::DeviceInstall(format!("Unable to open AFC on {udid}: {error}"))
|
|
| 57 |
+ })?; |
|
| 58 |
+ if afc.get_file_info("PublicStaging").await.is_err() {
|
|
| 59 |
+ afc.mk_dir("PublicStaging").await.map_err(|error| {
|
|
| 60 |
+ BackendError::DeviceInstall(format!( |
|
| 61 |
+ "Unable to create the device staging directory: {error}"
|
|
| 62 |
+ )) |
|
| 63 |
+ })?; |
|
| 64 |
+ } |
|
| 65 |
+ |
|
| 66 |
+ let app_folder_name = app_path |
|
| 67 |
+ .file_name() |
|
| 68 |
+ .and_then(|name| name.to_str()) |
|
| 69 |
+ .filter(|name| name.ends_with(".app"))
|
|
| 70 |
+ .ok_or_else(|| {
|
|
| 71 |
+ BackendError::DeviceInstall(format!( |
|
| 72 |
+ "Signed app path {} does not end in an .app folder.",
|
|
| 73 |
+ app_path.display() |
|
| 74 |
+ )) |
|
| 75 |
+ })?; |
|
| 76 |
+ let remote_path = format!("PublicStaging/{app_folder_name}");
|
|
| 77 |
+ if afc.get_file_info(&remote_path).await.is_ok() {
|
|
| 78 |
+ afc.remove_all(&remote_path).await.map_err(|error| {
|
|
| 79 |
+ BackendError::DeviceInstall(format!( |
|
| 80 |
+ "Unable to replace the existing staged app folder: {error}"
|
|
| 81 |
+ )) |
|
| 82 |
+ })?; |
|
| 83 |
+ } |
|
| 84 |
+ afc.mk_dir(&remote_path).await.map_err(|error| {
|
|
| 85 |
+ BackendError::DeviceInstall(format!("Unable to create the staged app folder: {error}"))
|
|
| 86 |
+ })?; |
|
| 87 |
+ if let Err(error) = upload_app_bundle(&mut afc, &remote_path, &upload_plan, &progress).await {
|
|
| 88 |
+ remove_staged_app(&mut afc, &remote_path).await; |
|
| 89 |
+ return Err(error); |
|
| 90 |
+ } |
|
| 91 |
+ |
|
| 92 |
+ let mut options = plist::Dictionary::new(); |
|
| 93 |
+ options.insert( |
|
| 94 |
+ "PackageType".to_string(), |
|
| 95 |
+ Value::String("Developer".to_string()),
|
|
| 96 |
+ ); |
|
| 97 |
+ let mut installer = match InstallationProxyClient::connect(&provider).await {
|
|
| 98 |
+ Ok(installer) => installer, |
|
| 99 |
+ Err(error) => {
|
|
| 100 |
+ remove_staged_app(&mut afc, &remote_path).await; |
|
| 101 |
+ return Err(BackendError::DeviceInstall(format!( |
|
| 102 |
+ "Unable to open InstallationProxy on {udid}: {error}"
|
|
| 103 |
+ ))); |
|
| 104 |
+ } |
|
| 105 |
+ }; |
|
| 106 |
+ let install_progress = Arc::clone(&progress); |
|
| 107 |
+ let result = installer |
|
| 108 |
+ .install_with_callback( |
|
| 109 |
+ remote_path.clone(), |
|
| 110 |
+ Some(Value::Dictionary(options)), |
|
| 111 |
+ move |(percent, progress)| async move {
|
|
| 112 |
+ report_install_progress(&progress, DeviceInstallProgress::Installing { percent });
|
|
| 113 |
+ }, |
|
| 114 |
+ install_progress, |
|
| 115 |
+ ) |
|
| 116 |
+ .await; |
|
| 117 |
+ |
|
| 118 |
+ report_install_progress(&progress, DeviceInstallProgress::Finalizing); |
|
| 119 |
+ remove_staged_app(&mut afc, &remote_path).await; |
|
| 120 |
+ drop(signed_app); |
|
| 121 |
+ |
|
| 122 |
+ result.map_err(|error| {
|
|
| 123 |
+ BackendError::DeviceInstall(format!("Unable to install the app on {udid}: {error}"))
|
|
| 124 |
+ }) |
|
| 125 |
+} |
|
| 126 |
+ |
|
| 127 |
+struct AppUploadPlan {
|
|
| 128 |
+ entries: Vec<AppUploadEntry>, |
|
| 129 |
+ total_bytes: u64, |
|
| 130 |
+ total_files: usize, |
|
| 131 |
+} |
|
| 132 |
+ |
|
| 133 |
+struct AppUploadEntry {
|
|
| 134 |
+ local_path: PathBuf, |
|
| 135 |
+ relative_path: String, |
|
| 136 |
+ is_directory: bool, |
|
| 137 |
+} |
|
| 138 |
+ |
|
| 139 |
+fn app_upload_plan(app_path: &Path) -> BackendResult<AppUploadPlan> {
|
|
| 140 |
+ let mut entries = Vec::new(); |
|
| 141 |
+ let mut total_bytes = 0_u64; |
|
| 142 |
+ let mut total_files = 0_usize; |
|
| 143 |
+ |
|
| 144 |
+ for entry in WalkDir::new(app_path).min_depth(1).sort_by_file_name() {
|
|
| 145 |
+ let entry = entry.map_err(|error| {
|
|
| 146 |
+ BackendError::DeviceInstall(format!("Unable to inspect signed app files: {error}"))
|
|
| 147 |
+ })?; |
|
| 148 |
+ let relative = entry.path().strip_prefix(app_path).map_err(|error| {
|
|
| 149 |
+ BackendError::DeviceInstall(format!("Unable to stage signed app path: {error}"))
|
|
| 150 |
+ })?; |
|
| 151 |
+ let relative_path = device_relative_path(relative)?; |
|
| 152 |
+ let file_type = entry.file_type(); |
|
| 153 |
+ if file_type.is_dir() {
|
|
| 154 |
+ entries.push(AppUploadEntry {
|
|
| 155 |
+ local_path: entry.into_path(), |
|
| 156 |
+ relative_path, |
|
| 157 |
+ is_directory: true, |
|
| 158 |
+ }); |
|
| 159 |
+ } else if file_type.is_file() {
|
|
| 160 |
+ let metadata = entry.metadata().map_err(|error| {
|
|
| 161 |
+ BackendError::DeviceInstall(format!( |
|
| 162 |
+ "Unable to inspect {}: {error}",
|
|
| 163 |
+ entry.path().display() |
|
| 164 |
+ )) |
|
| 165 |
+ })?; |
|
| 166 |
+ total_bytes = total_bytes.saturating_add(metadata.len()); |
|
| 167 |
+ total_files += 1; |
|
| 168 |
+ entries.push(AppUploadEntry {
|
|
| 169 |
+ local_path: entry.into_path(), |
|
| 170 |
+ relative_path, |
|
| 171 |
+ is_directory: false, |
|
| 172 |
+ }); |
|
| 173 |
+ } else {
|
|
| 174 |
+ return Err(BackendError::DeviceInstall(format!( |
|
| 175 |
+ "Signed app contains unsupported file {}.",
|
|
| 176 |
+ entry.path().display() |
|
| 177 |
+ ))); |
|
| 178 |
+ } |
|
| 179 |
+ } |
|
| 180 |
+ |
|
| 181 |
+ Ok(AppUploadPlan {
|
|
| 182 |
+ entries, |
|
| 183 |
+ total_bytes, |
|
| 184 |
+ total_files, |
|
| 185 |
+ }) |
|
| 186 |
+} |
|
| 187 |
+ |
|
| 188 |
+fn device_relative_path(path: &Path) -> BackendResult<String> {
|
|
| 189 |
+ let mut parts = Vec::new(); |
|
| 190 |
+ for component in path.components() {
|
|
| 191 |
+ let Component::Normal(component) = component else {
|
|
| 192 |
+ return Err(BackendError::DeviceInstall(format!( |
|
| 193 |
+ "Signed app contains unsafe path {}.",
|
|
| 194 |
+ path.display() |
|
| 195 |
+ ))); |
|
| 196 |
+ }; |
|
| 197 |
+ parts.push(component.to_string_lossy().into_owned()); |
|
| 198 |
+ } |
|
| 199 |
+ if parts.is_empty() {
|
|
| 200 |
+ return Err(BackendError::DeviceInstall( |
|
| 201 |
+ "Signed app contains an empty relative path.".to_string(), |
|
| 202 |
+ )); |
|
| 203 |
+ } |
|
| 204 |
+ Ok(parts.join("/"))
|
|
| 205 |
+} |
|
| 206 |
+ |
|
| 207 |
+async fn upload_app_bundle<P>( |
|
| 208 |
+ afc: &mut AfcClient, |
|
| 209 |
+ remote_root: &str, |
|
| 210 |
+ plan: &AppUploadPlan, |
|
| 211 |
+ progress: &Arc<Mutex<P>>, |
|
| 212 |
+) -> BackendResult<()> |
|
| 213 |
+where |
|
| 214 |
+ P: FnMut(DeviceInstallProgress), |
|
| 215 |
+{
|
|
| 216 |
+ let mut transferred_bytes = 0_u64; |
|
| 217 |
+ let mut completed_files = 0_usize; |
|
| 218 |
+ report_install_progress( |
|
| 219 |
+ progress, |
|
| 220 |
+ DeviceInstallProgress::Uploading {
|
|
| 221 |
+ transferred_bytes, |
|
| 222 |
+ total_bytes: plan.total_bytes, |
|
| 223 |
+ completed_files, |
|
| 224 |
+ total_files: plan.total_files, |
|
| 225 |
+ }, |
|
| 226 |
+ ); |
|
| 227 |
+ |
|
| 228 |
+ for entry in &plan.entries {
|
|
| 229 |
+ let remote_path = format!("{remote_root}/{}", entry.relative_path);
|
|
| 230 |
+ if entry.is_directory {
|
|
| 231 |
+ afc.mk_dir(&remote_path).await.map_err(|error| {
|
|
| 232 |
+ BackendError::DeviceInstall(format!( |
|
| 233 |
+ "Unable to create staged folder {remote_path}: {error}"
|
|
| 234 |
+ )) |
|
| 235 |
+ })?; |
|
| 236 |
+ continue; |
|
| 237 |
+ } |
|
| 238 |
+ |
|
| 239 |
+ let mut source = tokio::fs::File::open(&entry.local_path) |
|
| 240 |
+ .await |
|
| 241 |
+ .map_err(|error| {
|
|
| 242 |
+ BackendError::DeviceInstall(format!( |
|
| 243 |
+ "Unable to open signed app file {}: {error}",
|
|
| 244 |
+ entry.local_path.display() |
|
| 245 |
+ )) |
|
| 246 |
+ })?; |
|
| 247 |
+ let mut destination = |
|
| 248 |
+ afc.open(&remote_path, AfcFopenMode::WrOnly) |
|
| 249 |
+ .await |
|
| 250 |
+ .map_err(|error| {
|
|
| 251 |
+ BackendError::DeviceInstall(format!( |
|
| 252 |
+ "Unable to stage app file {remote_path}: {error}"
|
|
| 253 |
+ )) |
|
| 254 |
+ })?; |
|
| 255 |
+ let mut buffer = vec![0; APP_UPLOAD_BUFFER_SIZE]; |
|
| 256 |
+ let upload_result = async {
|
|
| 257 |
+ loop {
|
|
| 258 |
+ let read = source.read(&mut buffer).await.map_err(|error| {
|
|
| 259 |
+ BackendError::DeviceInstall(format!( |
|
| 260 |
+ "Unable to read signed app file {}: {error}",
|
|
| 261 |
+ entry.local_path.display() |
|
| 262 |
+ )) |
|
| 263 |
+ })?; |
|
| 264 |
+ if read == 0 {
|
|
| 265 |
+ return Ok(()); |
|
| 266 |
+ } |
|
| 267 |
+ destination |
|
| 268 |
+ .write_all(&buffer[..read]) |
|
| 269 |
+ .await |
|
| 270 |
+ .map_err(|error| {
|
|
| 271 |
+ BackendError::DeviceInstall(format!( |
|
| 272 |
+ "Unable to upload app file {remote_path}: {error}"
|
|
| 273 |
+ )) |
|
| 274 |
+ })?; |
|
| 275 |
+ transferred_bytes = transferred_bytes.saturating_add(read as u64); |
|
| 276 |
+ report_install_progress( |
|
| 277 |
+ progress, |
|
| 278 |
+ DeviceInstallProgress::Uploading {
|
|
| 279 |
+ transferred_bytes, |
|
| 280 |
+ total_bytes: plan.total_bytes, |
|
| 281 |
+ completed_files, |
|
| 282 |
+ total_files: plan.total_files, |
|
| 283 |
+ }, |
|
| 284 |
+ ); |
|
| 285 |
+ } |
|
| 286 |
+ } |
|
| 287 |
+ .await; |
|
| 288 |
+ let close_result = destination.close().await.map_err(|error| {
|
|
| 289 |
+ BackendError::DeviceInstall(format!("Unable to finish app file {remote_path}: {error}"))
|
|
| 290 |
+ }); |
|
| 291 |
+ upload_result.and(close_result)?; |
|
| 292 |
+ completed_files += 1; |
|
| 293 |
+ report_install_progress( |
|
| 294 |
+ progress, |
|
| 295 |
+ DeviceInstallProgress::Uploading {
|
|
| 296 |
+ transferred_bytes, |
|
| 297 |
+ total_bytes: plan.total_bytes, |
|
| 298 |
+ completed_files, |
|
| 299 |
+ total_files: plan.total_files, |
|
| 300 |
+ }, |
|
| 301 |
+ ); |
|
| 302 |
+ } |
|
| 303 |
+ |
|
| 304 |
+ Ok(()) |
|
| 305 |
+} |
|
| 306 |
+ |
|
| 307 |
+async fn remove_staged_app(afc: &mut AfcClient, remote_path: &str) {
|
|
| 308 |
+ if let Err(error) = afc.remove_all(remote_path).await {
|
|
| 309 |
+ log::warn!("Unable to remove staged app {remote_path}: {error}");
|
|
| 310 |
+ } |
|
| 311 |
+} |
|
| 312 |
+ |
|
| 313 |
+async fn provider_for_udid(udid: &str) -> BackendResult<UsbmuxdProvider> {
|
|
| 314 |
+ let addr = UsbmuxdAddr::from_env_var().map_err(|error| {
|
|
| 315 |
+ BackendError::DeviceInstall(format!("Invalid usbmuxd address: {error}"))
|
|
| 316 |
+ })?; |
|
| 317 |
+ let mut usbmuxd = addr.connect(100).await.map_err(|error| {
|
|
| 318 |
+ BackendError::DeviceInstall(format!("Unable to connect to usbmuxd: {error}"))
|
|
| 319 |
+ })?; |
|
| 320 |
+ let device = usbmuxd |
|
| 321 |
+ .get_devices() |
|
| 322 |
+ .await |
|
| 323 |
+ .map_err(|error| {
|
|
| 324 |
+ BackendError::DeviceInstall(format!("Unable to list connected devices: {error}"))
|
|
| 325 |
+ })? |
|
| 326 |
+ .into_iter() |
|
| 327 |
+ .find(|device| device.udid == udid) |
|
| 328 |
+ .ok_or_else(|| {
|
|
| 329 |
+ BackendError::DeviceInstall(format!( |
|
| 330 |
+ "Device {udid} is no longer connected. Reconnect it and try again."
|
|
| 331 |
+ )) |
|
| 332 |
+ })?; |
|
| 333 |
+ |
|
| 334 |
+ Ok(UsbmuxdProvider {
|
|
| 335 |
+ addr, |
|
| 336 |
+ tag: 101, |
|
| 337 |
+ udid: device.udid, |
|
| 338 |
+ device_id: device.device_id, |
|
| 339 |
+ label: "super-sideloader.install".to_string(), |
|
| 340 |
+ }) |
|
| 341 |
+} |
|
| 342 |
+ |
|
| 343 |
+fn report_install_progress<P>(progress: &Arc<Mutex<P>>, event: DeviceInstallProgress) |
|
| 344 |
+where |
|
| 345 |
+ P: FnMut(DeviceInstallProgress), |
|
| 346 |
+{
|
|
| 347 |
+ if let Ok(mut progress) = progress.lock() {
|
|
| 348 |
+ progress(event); |
|
| 349 |
+ } |
|
| 350 |
+} |
|
| 351 |
+ |
|
| 22 | 352 |
async fn watch_device_changes_async( |
| 23 | 353 |
sender: UnboundedSender<DeviceWatchEvent>, |
| 24 | 354 |
) -> BackendResult<()> {
|
| ... | ... |
@@ -139,3 +469,31 @@ fn os_name(device_class: Option<&str>, model: &str) -> &'static str {
|
| 139 | 469 |
_ => "iOS", |
| 140 | 470 |
} |
| 141 | 471 |
} |
| 472 |
+ |
|
| 473 |
+#[cfg(test)] |
|
| 474 |
+mod tests {
|
|
| 475 |
+ use super::*; |
|
| 476 |
+ use std::fs; |
|
| 477 |
+ |
|
| 478 |
+ #[test] |
|
| 479 |
+ fn app_upload_plan_counts_files_bytes_and_device_paths() {
|
|
| 480 |
+ let temp = tempfile::tempdir().unwrap(); |
|
| 481 |
+ let app = temp.path().join("Example.app");
|
|
| 482 |
+ let plug_in = app.join("PlugIns").join("Widget.appex");
|
|
| 483 |
+ fs::create_dir_all(&plug_in).unwrap(); |
|
| 484 |
+ fs::write(app.join("Info.plist"), b"plist").unwrap();
|
|
| 485 |
+ fs::write(plug_in.join("Widget"), b"executable").unwrap();
|
|
| 486 |
+ |
|
| 487 |
+ let plan = app_upload_plan(&app).unwrap(); |
|
| 488 |
+ |
|
| 489 |
+ assert_eq!(plan.total_files, 2); |
|
| 490 |
+ assert_eq!(plan.total_bytes, 15); |
|
| 491 |
+ assert!(plan.entries.iter().any(|entry| {
|
|
| 492 |
+ entry.relative_path == "PlugIns/Widget.appex/Widget" && !entry.is_directory |
|
| 493 |
+ })); |
|
| 494 |
+ assert_eq!( |
|
| 495 |
+ device_relative_path(Path::new("Frameworks/Example.framework/Example")).unwrap(),
|
|
| 496 |
+ "Frameworks/Example.framework/Example" |
|
| 497 |
+ ); |
|
| 498 |
+ } |
|
| 499 |
+} |
| ... | ... |
@@ -36,6 +36,8 @@ pub(crate) enum BackendError {
|
| 36 | 36 |
Adi(String), |
| 37 | 37 |
#[error("Device discovery failed: {0}")]
|
| 38 | 38 |
DeviceDiscovery(String), |
| 39 |
+ #[error("Device installation failed: {0}")]
|
|
| 40 |
+ DeviceInstall(String), |
|
| 39 | 41 |
#[error("Cache operation failed: {0}")]
|
| 40 | 42 |
Cache(String), |
| 41 | 43 |
#[error("Preferences operation failed: {0}")]
|
| ... | ... |
@@ -112,6 +114,7 @@ mod tests {
|
| 112 | 114 |
BackendError::AppleAuth("invalid password".to_string()).user_message(),
|
| 113 | 115 |
BackendError::Adi("not provisioned".to_string()).user_message(),
|
| 114 | 116 |
BackendError::DeviceDiscovery("usbmuxd stopped".to_string()).user_message(),
|
| 117 |
+ BackendError::DeviceInstall("verification failed".to_string()).user_message(),
|
|
| 115 | 118 |
BackendError::Cache("cache corrupt".to_string()).user_message(),
|
| 116 | 119 |
BackendError::Preferences("settings corrupt".to_string()).user_message(),
|
| 117 | 120 |
BackendError::Unsupported("not on this platform".to_string()).user_message(),
|
| ... | ... |
@@ -0,0 +1,389 @@ |
| 1 |
+use crate::backend::{BackendError, BackendResult};
|
|
| 2 |
+use crc32fast::Hasher; |
|
| 3 |
+use flate2::read::DeflateDecoder; |
|
| 4 |
+use flate2::write::ZlibEncoder; |
|
| 5 |
+use flate2::Compression; |
|
| 6 |
+use image::imageops::FilterType; |
|
| 7 |
+use image::{DynamicImage, ImageFormat, Rgba};
|
|
| 8 |
+use std::fs; |
|
| 9 |
+use std::io::{Cursor, Read, Write};
|
|
| 10 |
+use std::path::Path; |
|
| 11 |
+ |
|
| 12 |
+const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; |
|
| 13 |
+const GENERATED_ICON_PREFIX: &str = "SuperSideloaderIcon"; |
|
| 14 |
+ |
|
| 15 |
+pub(crate) const IPHONE_ICON_NAMES: &[&str] = &[ |
|
| 16 |
+ "SuperSideloaderIcon20", |
|
| 17 |
+ "SuperSideloaderIcon29", |
|
| 18 |
+ "SuperSideloaderIcon40", |
|
| 19 |
+ "SuperSideloaderIcon60", |
|
| 20 |
+]; |
|
| 21 |
+pub(crate) const IPAD_ICON_NAMES: &[&str] = &[ |
|
| 22 |
+ "SuperSideloaderIcon20", |
|
| 23 |
+ "SuperSideloaderIcon29", |
|
| 24 |
+ "SuperSideloaderIcon40", |
|
| 25 |
+ "SuperSideloaderIcon76", |
|
| 26 |
+ "SuperSideloaderIcon83_5", |
|
| 27 |
+]; |
|
| 28 |
+pub(crate) const IPHONE_PRIMARY_ICON_NAME: &str = "SuperSideloaderIcon60"; |
|
| 29 |
+pub(crate) const IPAD_PRIMARY_ICON_NAME: &str = "SuperSideloaderIcon76"; |
|
| 30 |
+ |
|
| 31 |
+struct IconVariant {
|
|
| 32 |
+ file_name: &'static str, |
|
| 33 |
+ pixels: u32, |
|
| 34 |
+} |
|
| 35 |
+ |
|
| 36 |
+const ICON_VARIANTS: &[IconVariant] = &[ |
|
| 37 |
+ IconVariant {
|
|
| 38 |
+ file_name: "SuperSideloaderIcon20.png", |
|
| 39 |
+ pixels: 20, |
|
| 40 |
+ }, |
|
| 41 |
+ IconVariant {
|
|
| 42 |
+ file_name: "SuperSideloaderIcon20@2x.png", |
|
| 43 |
+ pixels: 40, |
|
| 44 |
+ }, |
|
| 45 |
+ IconVariant {
|
|
| 46 |
+ file_name: "SuperSideloaderIcon20@3x.png", |
|
| 47 |
+ pixels: 60, |
|
| 48 |
+ }, |
|
| 49 |
+ IconVariant {
|
|
| 50 |
+ file_name: "SuperSideloaderIcon29.png", |
|
| 51 |
+ pixels: 29, |
|
| 52 |
+ }, |
|
| 53 |
+ IconVariant {
|
|
| 54 |
+ file_name: "SuperSideloaderIcon29@2x.png", |
|
| 55 |
+ pixels: 58, |
|
| 56 |
+ }, |
|
| 57 |
+ IconVariant {
|
|
| 58 |
+ file_name: "SuperSideloaderIcon29@3x.png", |
|
| 59 |
+ pixels: 87, |
|
| 60 |
+ }, |
|
| 61 |
+ IconVariant {
|
|
| 62 |
+ file_name: "SuperSideloaderIcon40.png", |
|
| 63 |
+ pixels: 40, |
|
| 64 |
+ }, |
|
| 65 |
+ IconVariant {
|
|
| 66 |
+ file_name: "SuperSideloaderIcon40@2x.png", |
|
| 67 |
+ pixels: 80, |
|
| 68 |
+ }, |
|
| 69 |
+ IconVariant {
|
|
| 70 |
+ file_name: "SuperSideloaderIcon40@3x.png", |
|
| 71 |
+ pixels: 120, |
|
| 72 |
+ }, |
|
| 73 |
+ IconVariant {
|
|
| 74 |
+ file_name: "SuperSideloaderIcon60.png", |
|
| 75 |
+ pixels: 60, |
|
| 76 |
+ }, |
|
| 77 |
+ IconVariant {
|
|
| 78 |
+ file_name: "SuperSideloaderIcon60@2x.png", |
|
| 79 |
+ pixels: 120, |
|
| 80 |
+ }, |
|
| 81 |
+ IconVariant {
|
|
| 82 |
+ file_name: "SuperSideloaderIcon60@3x.png", |
|
| 83 |
+ pixels: 180, |
|
| 84 |
+ }, |
|
| 85 |
+ IconVariant {
|
|
| 86 |
+ file_name: "SuperSideloaderIcon76.png", |
|
| 87 |
+ pixels: 76, |
|
| 88 |
+ }, |
|
| 89 |
+ IconVariant {
|
|
| 90 |
+ file_name: "SuperSideloaderIcon76@2x.png", |
|
| 91 |
+ pixels: 152, |
|
| 92 |
+ }, |
|
| 93 |
+ IconVariant {
|
|
| 94 |
+ file_name: "SuperSideloaderIcon83_5@2x.png", |
|
| 95 |
+ pixels: 167, |
|
| 96 |
+ }, |
|
| 97 |
+]; |
|
| 98 |
+ |
|
| 99 |
+pub(crate) fn normalize_png_for_display(bytes: &[u8]) -> Result<Vec<u8>, String> {
|
|
| 100 |
+ let image = decode_png(bytes)?; |
|
| 101 |
+ encode_png(&image) |
|
| 102 |
+} |
|
| 103 |
+ |
|
| 104 |
+pub(crate) fn write_icon_variants(app_path: &Path, source_path: &Path) -> BackendResult<()> {
|
|
| 105 |
+ let source_bytes = fs::read(source_path).map_err(|source| BackendError::Io {
|
|
| 106 |
+ action: "Read app icon override", |
|
| 107 |
+ path: source_path.to_path_buf(), |
|
| 108 |
+ source, |
|
| 109 |
+ })?; |
|
| 110 |
+ let source = decode_png(&source_bytes).map_err(|error| {
|
|
| 111 |
+ BackendError::Message(format!( |
|
| 112 |
+ "Failed to decode app icon override {}: {error}",
|
|
| 113 |
+ source_path.display() |
|
| 114 |
+ )) |
|
| 115 |
+ })?; |
|
| 116 |
+ if source.width() != source.height() {
|
|
| 117 |
+ return Err(BackendError::Message(format!( |
|
| 118 |
+ "The app icon override must be square, but it is {}x{} pixels.",
|
|
| 119 |
+ source.width(), |
|
| 120 |
+ source.height() |
|
| 121 |
+ ))); |
|
| 122 |
+ } |
|
| 123 |
+ |
|
| 124 |
+ remove_previous_variants(app_path)?; |
|
| 125 |
+ for variant in ICON_VARIANTS {
|
|
| 126 |
+ let destination = app_path.join(variant.file_name); |
|
| 127 |
+ source |
|
| 128 |
+ .resize_exact(variant.pixels, variant.pixels, FilterType::Lanczos3) |
|
| 129 |
+ .save_with_format(&destination, ImageFormat::Png) |
|
| 130 |
+ .map_err(|error| {
|
|
| 131 |
+ BackendError::Message(format!( |
|
| 132 |
+ "Failed to write app icon variant {}: {error}",
|
|
| 133 |
+ destination.display() |
|
| 134 |
+ )) |
|
| 135 |
+ })?; |
|
| 136 |
+ } |
|
| 137 |
+ Ok(()) |
|
| 138 |
+} |
|
| 139 |
+ |
|
| 140 |
+fn decode_png(bytes: &[u8]) -> Result<DynamicImage, String> {
|
|
| 141 |
+ let chunks = parse_png_chunks(bytes)?; |
|
| 142 |
+ if chunks.iter().any(|chunk| chunk.kind == *b"CgBI") {
|
|
| 143 |
+ decode_cgbi_png(&chunks) |
|
| 144 |
+ } else {
|
|
| 145 |
+ image::load_from_memory_with_format(bytes, ImageFormat::Png) |
|
| 146 |
+ .map_err(|error| format!("invalid PNG: {error}"))
|
|
| 147 |
+ } |
|
| 148 |
+} |
|
| 149 |
+ |
|
| 150 |
+fn decode_cgbi_png(chunks: &[PngChunk<'_>]) -> Result<DynamicImage, String> {
|
|
| 151 |
+ let ihdr = chunks |
|
| 152 |
+ .iter() |
|
| 153 |
+ .find(|chunk| chunk.kind == *b"IHDR") |
|
| 154 |
+ .ok_or_else(|| "CgBI PNG has no IHDR chunk".to_string())?; |
|
| 155 |
+ if ihdr.data.len() != 13 || ihdr.data[8] != 8 || ihdr.data[9] != 6 {
|
|
| 156 |
+ return Err("CgBI PNG must use 8-bit RGBA pixels".to_string());
|
|
| 157 |
+ } |
|
| 158 |
+ |
|
| 159 |
+ let compressed = chunks |
|
| 160 |
+ .iter() |
|
| 161 |
+ .filter(|chunk| chunk.kind == *b"IDAT") |
|
| 162 |
+ .flat_map(|chunk| chunk.data.iter().copied()) |
|
| 163 |
+ .collect::<Vec<_>>(); |
|
| 164 |
+ if compressed.is_empty() {
|
|
| 165 |
+ return Err("CgBI PNG has no IDAT data".to_string());
|
|
| 166 |
+ } |
|
| 167 |
+ |
|
| 168 |
+ let mut filtered_pixels = Vec::new(); |
|
| 169 |
+ DeflateDecoder::new(compressed.as_slice()) |
|
| 170 |
+ .read_to_end(&mut filtered_pixels) |
|
| 171 |
+ .map_err(|error| format!("failed to inflate CgBI pixel data: {error}"))?;
|
|
| 172 |
+ let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); |
|
| 173 |
+ encoder |
|
| 174 |
+ .write_all(&filtered_pixels) |
|
| 175 |
+ .map_err(|error| format!("failed to recompress CgBI pixel data: {error}"))?;
|
|
| 176 |
+ let standard_idat = encoder |
|
| 177 |
+ .finish() |
|
| 178 |
+ .map_err(|error| format!("failed to finish CgBI pixel data: {error}"))?;
|
|
| 179 |
+ |
|
| 180 |
+ let mut standard_png = PNG_SIGNATURE.to_vec(); |
|
| 181 |
+ let mut wrote_idat = false; |
|
| 182 |
+ for chunk in chunks {
|
|
| 183 |
+ match &chunk.kind {
|
|
| 184 |
+ b"CgBI" => {}
|
|
| 185 |
+ b"IDAT" if !wrote_idat => {
|
|
| 186 |
+ write_png_chunk(&mut standard_png, *b"IDAT", &standard_idat)?; |
|
| 187 |
+ wrote_idat = true; |
|
| 188 |
+ } |
|
| 189 |
+ b"IDAT" => {}
|
|
| 190 |
+ _ => write_png_chunk(&mut standard_png, chunk.kind, chunk.data)?, |
|
| 191 |
+ } |
|
| 192 |
+ } |
|
| 193 |
+ |
|
| 194 |
+ let mut image = image::load_from_memory_with_format(&standard_png, ImageFormat::Png) |
|
| 195 |
+ .map_err(|error| format!("failed to decode normalized CgBI PNG: {error}"))?
|
|
| 196 |
+ .to_rgba8(); |
|
| 197 |
+ for pixel in image.pixels_mut() {
|
|
| 198 |
+ let [blue, green, red, alpha] = pixel.0; |
|
| 199 |
+ *pixel = Rgba([ |
|
| 200 |
+ unpremultiply(red, alpha), |
|
| 201 |
+ unpremultiply(green, alpha), |
|
| 202 |
+ unpremultiply(blue, alpha), |
|
| 203 |
+ alpha, |
|
| 204 |
+ ]); |
|
| 205 |
+ } |
|
| 206 |
+ Ok(DynamicImage::ImageRgba8(image)) |
|
| 207 |
+} |
|
| 208 |
+ |
|
| 209 |
+fn encode_png(image: &DynamicImage) -> Result<Vec<u8>, String> {
|
|
| 210 |
+ let mut output = Cursor::new(Vec::new()); |
|
| 211 |
+ image |
|
| 212 |
+ .write_to(&mut output, ImageFormat::Png) |
|
| 213 |
+ .map_err(|error| format!("failed to encode PNG: {error}"))?;
|
|
| 214 |
+ Ok(output.into_inner()) |
|
| 215 |
+} |
|
| 216 |
+ |
|
| 217 |
+fn unpremultiply(value: u8, alpha: u8) -> u8 {
|
|
| 218 |
+ if alpha == 0 {
|
|
| 219 |
+ return 0; |
|
| 220 |
+ } |
|
| 221 |
+ ((u32::from(value) * 255 + u32::from(alpha) / 2) / u32::from(alpha)).min(255) as u8 |
|
| 222 |
+} |
|
| 223 |
+ |
|
| 224 |
+struct PngChunk<'a> {
|
|
| 225 |
+ kind: [u8; 4], |
|
| 226 |
+ data: &'a [u8], |
|
| 227 |
+} |
|
| 228 |
+ |
|
| 229 |
+fn parse_png_chunks(bytes: &[u8]) -> Result<Vec<PngChunk<'_>>, String> {
|
|
| 230 |
+ if !bytes.starts_with(PNG_SIGNATURE) {
|
|
| 231 |
+ return Err("file is not a PNG".to_string());
|
|
| 232 |
+ } |
|
| 233 |
+ |
|
| 234 |
+ let mut chunks = Vec::new(); |
|
| 235 |
+ let mut offset = PNG_SIGNATURE.len(); |
|
| 236 |
+ while offset < bytes.len() {
|
|
| 237 |
+ let header_end = offset |
|
| 238 |
+ .checked_add(8) |
|
| 239 |
+ .filter(|end| *end <= bytes.len()) |
|
| 240 |
+ .ok_or_else(|| "PNG has a truncated chunk header".to_string())?; |
|
| 241 |
+ let length = u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize; |
|
| 242 |
+ let kind = bytes[offset + 4..header_end].try_into().unwrap(); |
|
| 243 |
+ let data_start = header_end; |
|
| 244 |
+ let data_end = data_start |
|
| 245 |
+ .checked_add(length) |
|
| 246 |
+ .filter(|end| {
|
|
| 247 |
+ end.checked_add(4) |
|
| 248 |
+ .is_some_and(|crc_end| crc_end <= bytes.len()) |
|
| 249 |
+ }) |
|
| 250 |
+ .ok_or_else(|| "PNG has a truncated chunk".to_string())?; |
|
| 251 |
+ chunks.push(PngChunk {
|
|
| 252 |
+ kind, |
|
| 253 |
+ data: &bytes[data_start..data_end], |
|
| 254 |
+ }); |
|
| 255 |
+ offset = data_end + 4; |
|
| 256 |
+ if kind == *b"IEND" {
|
|
| 257 |
+ break; |
|
| 258 |
+ } |
|
| 259 |
+ } |
|
| 260 |
+ |
|
| 261 |
+ if !chunks.iter().any(|chunk| chunk.kind == *b"IHDR") {
|
|
| 262 |
+ return Err("PNG has no IHDR chunk".to_string());
|
|
| 263 |
+ } |
|
| 264 |
+ if !chunks.iter().any(|chunk| chunk.kind == *b"IEND") {
|
|
| 265 |
+ return Err("PNG has no IEND chunk".to_string());
|
|
| 266 |
+ } |
|
| 267 |
+ Ok(chunks) |
|
| 268 |
+} |
|
| 269 |
+ |
|
| 270 |
+fn write_png_chunk(output: &mut Vec<u8>, kind: [u8; 4], data: &[u8]) -> Result<(), String> {
|
|
| 271 |
+ let length = u32::try_from(data.len()).map_err(|_| "PNG chunk is too large".to_string())?; |
|
| 272 |
+ output.extend_from_slice(&length.to_be_bytes()); |
|
| 273 |
+ output.extend_from_slice(&kind); |
|
| 274 |
+ output.extend_from_slice(data); |
|
| 275 |
+ let mut crc = Hasher::new(); |
|
| 276 |
+ crc.update(&kind); |
|
| 277 |
+ crc.update(data); |
|
| 278 |
+ output.extend_from_slice(&crc.finalize().to_be_bytes()); |
|
| 279 |
+ Ok(()) |
|
| 280 |
+} |
|
| 281 |
+ |
|
| 282 |
+fn remove_previous_variants(app_path: &Path) -> BackendResult<()> {
|
|
| 283 |
+ let entries = fs::read_dir(app_path).map_err(|source| BackendError::Io {
|
|
| 284 |
+ action: "Read app bundle while replacing icons", |
|
| 285 |
+ path: app_path.to_path_buf(), |
|
| 286 |
+ source, |
|
| 287 |
+ })?; |
|
| 288 |
+ for entry in entries {
|
|
| 289 |
+ let entry = entry.map_err(|source| BackendError::Io {
|
|
| 290 |
+ action: "Read app bundle entry while replacing icons", |
|
| 291 |
+ path: app_path.to_path_buf(), |
|
| 292 |
+ source, |
|
| 293 |
+ })?; |
|
| 294 |
+ let path = entry.path(); |
|
| 295 |
+ let generated_png = path |
|
| 296 |
+ .file_name() |
|
| 297 |
+ .and_then(|name| name.to_str()) |
|
| 298 |
+ .is_some_and(|name| {
|
|
| 299 |
+ name.starts_with(GENERATED_ICON_PREFIX) |
|
| 300 |
+ && name.to_ascii_lowercase().ends_with(".png")
|
|
| 301 |
+ }); |
|
| 302 |
+ if generated_png {
|
|
| 303 |
+ fs::remove_file(&path).map_err(|source| BackendError::Io {
|
|
| 304 |
+ action: "Remove previous app icon variant", |
|
| 305 |
+ path, |
|
| 306 |
+ source, |
|
| 307 |
+ })?; |
|
| 308 |
+ } |
|
| 309 |
+ } |
|
| 310 |
+ Ok(()) |
|
| 311 |
+} |
|
| 312 |
+ |
|
| 313 |
+#[cfg(test)] |
|
| 314 |
+mod tests {
|
|
| 315 |
+ use super::*; |
|
| 316 |
+ use flate2::write::DeflateEncoder; |
|
| 317 |
+ use image::{GenericImageView, RgbaImage};
|
|
| 318 |
+ |
|
| 319 |
+ #[test] |
|
| 320 |
+ fn cgbi_png_is_normalized_to_standard_rgba_png() {
|
|
| 321 |
+ let cgbi = cgbi_test_png(); |
|
| 322 |
+ |
|
| 323 |
+ let normalized = normalize_png_for_display(&cgbi).unwrap(); |
|
| 324 |
+ assert!(!parse_png_chunks(&normalized) |
|
| 325 |
+ .unwrap() |
|
| 326 |
+ .iter() |
|
| 327 |
+ .any(|chunk| chunk.kind == *b"CgBI")); |
|
| 328 |
+ |
|
| 329 |
+ let image = image::load_from_memory_with_format(&normalized, ImageFormat::Png).unwrap(); |
|
| 330 |
+ assert_eq!(image.dimensions(), (2, 1)); |
|
| 331 |
+ assert_eq!(image.to_rgba8().get_pixel(0, 0).0, [128, 64, 32, 128]); |
|
| 332 |
+ assert_eq!(image.to_rgba8().get_pixel(1, 0).0, [20, 40, 80, 255]); |
|
| 333 |
+ } |
|
| 334 |
+ |
|
| 335 |
+ #[test] |
|
| 336 |
+ fn icon_variants_have_the_expected_pixel_sizes() {
|
|
| 337 |
+ let temp = tempfile::tempdir().unwrap(); |
|
| 338 |
+ let app_path = temp.path().join("Example.app");
|
|
| 339 |
+ fs::create_dir(&app_path).unwrap(); |
|
| 340 |
+ let source_path = temp.path().join("source.png");
|
|
| 341 |
+ RgbaImage::from_pixel(180, 180, Rgba([20, 40, 80, 255])) |
|
| 342 |
+ .save(&source_path) |
|
| 343 |
+ .unwrap(); |
|
| 344 |
+ |
|
| 345 |
+ write_icon_variants(&app_path, &source_path).unwrap(); |
|
| 346 |
+ |
|
| 347 |
+ for variant in ICON_VARIANTS {
|
|
| 348 |
+ let image = image::open(app_path.join(variant.file_name)).unwrap(); |
|
| 349 |
+ assert_eq!( |
|
| 350 |
+ image.dimensions(), |
|
| 351 |
+ (variant.pixels, variant.pixels), |
|
| 352 |
+ "{}",
|
|
| 353 |
+ variant.file_name |
|
| 354 |
+ ); |
|
| 355 |
+ } |
|
| 356 |
+ } |
|
| 357 |
+ |
|
| 358 |
+ #[test] |
|
| 359 |
+ fn icon_override_must_be_square() {
|
|
| 360 |
+ let temp = tempfile::tempdir().unwrap(); |
|
| 361 |
+ let app_path = temp.path().join("Example.app");
|
|
| 362 |
+ fs::create_dir(&app_path).unwrap(); |
|
| 363 |
+ let source_path = temp.path().join("source.png");
|
|
| 364 |
+ RgbaImage::new(180, 120).save(&source_path).unwrap(); |
|
| 365 |
+ |
|
| 366 |
+ let error = write_icon_variants(&app_path, &source_path).unwrap_err(); |
|
| 367 |
+ assert!(error.user_message().contains("must be square"));
|
|
| 368 |
+ } |
|
| 369 |
+ |
|
| 370 |
+ fn cgbi_test_png() -> Vec<u8> {
|
|
| 371 |
+ let mut raw_pixels = vec![0]; |
|
| 372 |
+ raw_pixels.extend_from_slice(&[16, 32, 64, 128]); |
|
| 373 |
+ raw_pixels.extend_from_slice(&[80, 40, 20, 255]); |
|
| 374 |
+ let mut deflater = DeflateEncoder::new(Vec::new(), Compression::default()); |
|
| 375 |
+ deflater.write_all(&raw_pixels).unwrap(); |
|
| 376 |
+ let idat = deflater.finish().unwrap(); |
|
| 377 |
+ |
|
| 378 |
+ let mut png = PNG_SIGNATURE.to_vec(); |
|
| 379 |
+ write_png_chunk(&mut png, *b"CgBI", &[0x40, 0, 0, 0]).unwrap(); |
|
| 380 |
+ let mut ihdr = Vec::new(); |
|
| 381 |
+ ihdr.extend_from_slice(&2u32.to_be_bytes()); |
|
| 382 |
+ ihdr.extend_from_slice(&1u32.to_be_bytes()); |
|
| 383 |
+ ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); |
|
| 384 |
+ write_png_chunk(&mut png, *b"IHDR", &ihdr).unwrap(); |
|
| 385 |
+ write_png_chunk(&mut png, *b"IDAT", &idat).unwrap(); |
|
| 386 |
+ write_png_chunk(&mut png, *b"IEND", &[]).unwrap(); |
|
| 387 |
+ png |
|
| 388 |
+ } |
|
| 389 |
+} |
| ... | ... |
@@ -1,20 +1,95 @@ |
| 1 |
+use crate::backend::icon::{
|
|
| 2 |
+ normalize_png_for_display, write_icon_variants, IPAD_ICON_NAMES, IPAD_PRIMARY_ICON_NAME, |
|
| 3 |
+ IPHONE_ICON_NAMES, IPHONE_PRIMARY_ICON_NAME, |
|
| 4 |
+}; |
|
| 1 | 5 |
use crate::backend::paths::app_data_dir; |
| 2 | 6 |
use crate::backend::{runtime as backend_runtime, BackendError, BackendResult};
|
| 3 | 7 |
use crate::domain::{
|
| 4 |
- AppEntitlement, AppMetadata, EntitlementValue, EntitlementsSource, IpaApp, Patch, |
|
| 8 |
+ AppEntitlement, AppMetadata, EntitlementValue, EntitlementsSource, IpaApp, NestedBundle, Patch, |
|
| 5 | 9 |
SupportedDeviceFamily, |
| 6 | 10 |
}; |
| 11 |
+use apple_codesign::{
|
|
| 12 |
+ sign_bundle, BundleSigningSettings, ProvisioningProfile, RustCryptoCmsSigner, |
|
| 13 |
+}; |
|
| 14 |
+use async_zip::base::read::mem::ZipFileReader as MemoryZipFileReader; |
|
| 15 |
+use async_zip::base::write::ZipFileWriter; |
|
| 7 | 16 |
use async_zip::tokio::read::fs::ZipFileReader; |
| 17 |
+use async_zip::{Compression, ZipEntry, ZipEntryBuilder};
|
|
| 18 |
+use flate2::write::DeflateEncoder; |
|
| 19 |
+use flate2::Compression as FlateCompression; |
|
| 8 | 20 |
use futures_lite::io::AsyncReadExt; |
| 9 | 21 |
use plist::{Dictionary, Value};
|
| 22 |
+use std::collections::BTreeMap; |
|
| 10 | 23 |
use std::fs; |
| 11 |
-use std::io::Cursor; |
|
| 12 |
-use std::path::PathBuf; |
|
| 24 |
+use std::io::{Cursor, Write as _};
|
|
| 25 |
+use std::path::{Component, Path, PathBuf};
|
|
| 26 |
+use tempfile::{Builder as TempBuilder, TempDir};
|
|
| 27 |
+use tokio::io::AsyncWriteExt; |
|
| 28 |
+use tokio::task::JoinSet; |
|
| 29 |
+use walkdir::WalkDir; |
|
| 30 |
+ |
|
| 31 |
+#[cfg(unix)] |
|
| 32 |
+use std::os::unix::fs::PermissionsExt; |
|
| 33 |
+ |
|
| 34 |
+const MAX_EXTRACTION_WORKERS: usize = 4; |
|
| 35 |
+const MAX_PACKAGING_WORKERS: usize = 4; |
|
| 36 |
+const FAST_DEFLATE_LEVEL: u32 = 4; |
|
| 37 |
+const IN_MEMORY_EXTRACTION_LIMIT: u64 = 256 * 1024 * 1024; |
|
| 38 |
+ |
|
| 39 |
+pub(crate) struct IpaSigningRequest {
|
|
| 40 |
+ pub(crate) source_path: PathBuf, |
|
| 41 |
+ pub(crate) destination: SigningDestination, |
|
| 42 |
+ pub(crate) metadata: AppMetadata, |
|
| 43 |
+ pub(crate) bundle_id_replacements: BTreeMap<String, String>, |
|
| 44 |
+ pub(crate) icon_override_path: Option<PathBuf>, |
|
| 45 |
+ pub(crate) team_id: String, |
|
| 46 |
+ pub(crate) provisioning_profiles: BTreeMap<String, Vec<u8>>, |
|
| 47 |
+ pub(crate) private_key_pem: Vec<u8>, |
|
| 48 |
+ pub(crate) certificate_der: Vec<u8>, |
|
| 49 |
+} |
|
| 50 |
+ |
|
| 51 |
+pub(crate) enum SigningDestination {
|
|
| 52 |
+ Ipa(PathBuf), |
|
| 53 |
+ AppBundle, |
|
| 54 |
+} |
|
| 55 |
+ |
|
| 56 |
+pub(crate) enum SigningArtifact {
|
|
| 57 |
+ Ipa(PathBuf), |
|
| 58 |
+ AppBundle(SignedAppBundle), |
|
| 59 |
+} |
|
| 60 |
+ |
|
| 61 |
+pub(crate) struct SignedAppBundle {
|
|
| 62 |
+ app_path: PathBuf, |
|
| 63 |
+ _work_dir: TempDir, |
|
| 64 |
+} |
|
| 65 |
+ |
|
| 66 |
+impl SignedAppBundle {
|
|
| 67 |
+ pub(crate) fn path(&self) -> &Path {
|
|
| 68 |
+ &self.app_path |
|
| 69 |
+ } |
|
| 70 |
+} |
|
| 71 |
+ |
|
| 72 |
+#[derive(Clone, Copy, Debug, Eq, PartialEq)] |
|
| 73 |
+pub(crate) enum IpaSigningProgress {
|
|
| 74 |
+ Extracting { completed: usize, total: usize },
|
|
| 75 |
+ Patching, |
|
| 76 |
+ Signing, |
|
| 77 |
+ Packaging { completed: usize, total: usize },
|
|
| 78 |
+ Saving, |
|
| 79 |
+ Ready, |
|
| 80 |
+} |
|
| 13 | 81 |
|
| 14 | 82 |
pub(crate) async fn read_ipa(path: PathBuf, patches: Vec<Patch>) -> BackendResult<IpaApp> {
|
| 15 | 83 |
backend_runtime::run_send("IPA reader", read_ipa_async(path, patches)).await?
|
| 16 | 84 |
} |
| 17 | 85 |
|
| 86 |
+pub(crate) async fn sign_ipa( |
|
| 87 |
+ request: IpaSigningRequest, |
|
| 88 |
+ progress: impl FnMut(IpaSigningProgress) + Send + 'static, |
|
| 89 |
+) -> BackendResult<SigningArtifact> {
|
|
| 90 |
+ backend_runtime::run_send("IPA signer", sign_ipa_async(request, progress)).await?
|
|
| 91 |
+} |
|
| 92 |
+ |
|
| 18 | 93 |
async fn read_ipa_async(path: PathBuf, patches: Vec<Patch>) -> BackendResult<IpaApp> {
|
| 19 | 94 |
let reader = ZipFileReader::new(&path) |
| 20 | 95 |
.await |
| ... | ... |
@@ -48,6 +123,7 @@ async fn read_ipa_async(path: PathBuf, patches: Vec<Patch>) -> BackendResult<Ipa |
| 48 | 123 |
.or_else(|| plist_string(&info, "CFBundleName")) |
| 49 | 124 |
.unwrap_or(executable); |
| 50 | 125 |
let supported_devices = supported_devices(&info); |
| 126 |
+ let nested_bundles = read_nested_signable_bundles(&reader, &app_prefix).await?; |
|
| 51 | 127 |
|
| 52 | 128 |
let icon_path = extract_icon(&reader, &app_prefix, &info, bundle_id).await; |
| 53 | 129 |
let (entitlements_source, entitlements) = match extract_entitlements(&reader, &app_prefix).await |
| ... | ... |
@@ -66,6 +142,7 @@ async fn read_ipa_async(path: PathBuf, patches: Vec<Patch>) -> BackendResult<Ipa |
| 66 | 142 |
minimum_os: minimum_os.to_string(), |
| 67 | 143 |
supported_devices, |
| 68 | 144 |
}, |
| 145 |
+ nested_bundles, |
|
| 69 | 146 |
path: path.to_string_lossy().to_string(), |
| 70 | 147 |
icon_path: icon_path.map(|path| path.to_string_lossy().to_string()), |
| 71 | 148 |
entitlements, |
| ... | ... |
@@ -74,10 +151,60 @@ async fn read_ipa_async(path: PathBuf, patches: Vec<Patch>) -> BackendResult<Ipa |
| 74 | 151 |
}) |
| 75 | 152 |
} |
| 76 | 153 |
|
| 154 |
+async fn read_nested_signable_bundles( |
|
| 155 |
+ reader: &ZipFileReader, |
|
| 156 |
+ app_prefix: &str, |
|
| 157 |
+) -> BackendResult<Vec<NestedBundle>> {
|
|
| 158 |
+ let candidates = reader |
|
| 159 |
+ .file() |
|
| 160 |
+ .entries() |
|
| 161 |
+ .iter() |
|
| 162 |
+ .enumerate() |
|
| 163 |
+ .filter_map(|(index, entry)| {
|
|
| 164 |
+ let path = entry_name(entry)?; |
|
| 165 |
+ is_nested_signable_info_plist(path, app_prefix).then(|| (index, path.to_string())) |
|
| 166 |
+ }) |
|
| 167 |
+ .collect::<Vec<_>>(); |
|
| 168 |
+ let mut bundles = Vec::with_capacity(candidates.len()); |
|
| 169 |
+ |
|
| 170 |
+ for (index, path) in candidates {
|
|
| 171 |
+ let bytes = read_entry(reader, index, &path).await?; |
|
| 172 |
+ let info = plist::from_bytes::<Dictionary>(&bytes) |
|
| 173 |
+ .map_err(|error| BackendError::Plist(format!("{path}: {error}")))?;
|
|
| 174 |
+ if plist_string(&info, "CFBundleExecutable").is_none() {
|
|
| 175 |
+ continue; |
|
| 176 |
+ } |
|
| 177 |
+ let bundle_id = required_plist_string(&info, "CFBundleIdentifier")?.to_string(); |
|
| 178 |
+ let name = plist_string(&info, "CFBundleDisplayName") |
|
| 179 |
+ .or_else(|| plist_string(&info, "CFBundleName")) |
|
| 180 |
+ .unwrap_or(&bundle_id) |
|
| 181 |
+ .to_string(); |
|
| 182 |
+ bundles.push(NestedBundle { name, bundle_id });
|
|
| 183 |
+ } |
|
| 184 |
+ |
|
| 185 |
+ bundles.sort_by(|left, right| left.bundle_id.cmp(&right.bundle_id)); |
|
| 186 |
+ bundles.dedup_by(|left, right| left.bundle_id == right.bundle_id); |
|
| 187 |
+ Ok(bundles) |
|
| 188 |
+} |
|
| 189 |
+ |
|
| 190 |
+fn is_nested_signable_info_plist(path: &str, app_prefix: &str) -> bool {
|
|
| 191 |
+ let Some(relative) = path |
|
| 192 |
+ .strip_prefix(app_prefix) |
|
| 193 |
+ .and_then(|path| path.strip_suffix("/Info.plist"))
|
|
| 194 |
+ else {
|
|
| 195 |
+ return false; |
|
| 196 |
+ }; |
|
| 197 |
+ !relative.is_empty() |
|
| 198 |
+ && relative |
|
| 199 |
+ .rsplit('/')
|
|
| 200 |
+ .next() |
|
| 201 |
+ .is_some_and(|folder| folder.ends_with(".appex") || folder.ends_with(".app"))
|
|
| 202 |
+} |
|
| 203 |
+ |
|
| 77 | 204 |
fn is_app_info_plist(path: &str) -> bool {
|
| 78 |
- path.starts_with("Payload/")
|
|
| 79 |
- && path.ends_with(".app/Info.plist")
|
|
| 80 |
- && path["Payload/".len()..].contains(".app/")
|
|
| 205 |
+ path.strip_prefix("Payload/")
|
|
| 206 |
+ .and_then(|path| path.strip_suffix("/Info.plist"))
|
|
| 207 |
+ .is_some_and(|bundle| bundle.ends_with(".app") && !bundle.contains('/'))
|
|
| 81 | 208 |
} |
| 82 | 209 |
|
| 83 | 210 |
fn entry_name(entry: &async_zip::ZipEntry) -> Option<&str> {
|
| ... | ... |
@@ -226,10 +353,17 @@ fn find_icon_entry<'a>( |
| 226 | 353 |
} |
| 227 | 354 |
|
| 228 | 355 |
fn cache_icon(bundle_id: &str, bytes: &[u8]) -> Option<PathBuf> {
|
| 356 |
+ let normalized = match normalize_png_for_display(bytes) {
|
|
| 357 |
+ Ok(normalized) => normalized, |
|
| 358 |
+ Err(error) => {
|
|
| 359 |
+ log::warn!("Failed to normalize app icon for {bundle_id}: {error}");
|
|
| 360 |
+ return None; |
|
| 361 |
+ } |
|
| 362 |
+ }; |
|
| 229 | 363 |
let mut path = app_data_dir()?.join("icons");
|
| 230 | 364 |
fs::create_dir_all(&path).ok()?; |
| 231 | 365 |
path.push(format!("{}.png", cache_safe_name(bundle_id)));
|
| 232 |
- fs::write(&path, bytes).ok()?; |
|
| 366 |
+ fs::write(&path, normalized).ok()?; |
|
| 233 | 367 |
Some(path) |
| 234 | 368 |
} |
| 235 | 369 |
|
| ... | ... |
@@ -308,18 +442,1056 @@ fn entitlement_value(value: &Value) -> EntitlementValue {
|
| 308 | 442 |
} |
| 309 | 443 |
} |
| 310 | 444 |
|
| 445 |
+async fn sign_ipa_async( |
|
| 446 |
+ request: IpaSigningRequest, |
|
| 447 |
+ mut progress: impl FnMut(IpaSigningProgress) + Send, |
|
| 448 |
+) -> BackendResult<SigningArtifact> {
|
|
| 449 |
+ let work_parent = app_data_dir() |
|
| 450 |
+ .ok_or_else(|| {
|
|
| 451 |
+ BackendError::Unsupported( |
|
| 452 |
+ "The application data folder is not available for IPA signing.".to_string(), |
|
| 453 |
+ ) |
|
| 454 |
+ })? |
|
| 455 |
+ .join("signing");
|
|
| 456 |
+ fs::create_dir_all(&work_parent).map_err(|source| BackendError::Io {
|
|
| 457 |
+ action: "Create IPA signing work folder", |
|
| 458 |
+ path: work_parent.clone(), |
|
| 459 |
+ source, |
|
| 460 |
+ })?; |
|
| 461 |
+ let work_dir = TempBuilder::new() |
|
| 462 |
+ .prefix("sign-")
|
|
| 463 |
+ .tempdir_in(&work_parent) |
|
| 464 |
+ .map_err(|source| BackendError::Io {
|
|
| 465 |
+ action: "Create temporary IPA signing folder", |
|
| 466 |
+ path: work_parent, |
|
| 467 |
+ source, |
|
| 468 |
+ })?; |
|
| 469 |
+ |
|
| 470 |
+ extract_ipa(&request.source_path, work_dir.path(), &mut progress).await?; |
|
| 471 |
+ let app_path = root_app_bundle(work_dir.path())?; |
|
| 472 |
+ progress(IpaSigningProgress::Patching); |
|
| 473 |
+ patch_app_bundle( |
|
| 474 |
+ &app_path, |
|
| 475 |
+ &request.metadata, |
|
| 476 |
+ request.icon_override_path.as_deref(), |
|
| 477 |
+ )?; |
|
| 478 |
+ patch_nested_bundle_identifiers(&app_path, &request.bundle_id_replacements)?; |
|
| 479 |
+ progress(IpaSigningProgress::Signing); |
|
| 480 |
+ sign_staged_app(&app_path, &request)?; |
|
| 481 |
+ |
|
| 482 |
+ if matches!(&request.destination, SigningDestination::AppBundle) {
|
|
| 483 |
+ progress(IpaSigningProgress::Ready); |
|
| 484 |
+ return Ok(SigningArtifact::AppBundle(SignedAppBundle {
|
|
| 485 |
+ app_path, |
|
| 486 |
+ _work_dir: work_dir, |
|
| 487 |
+ })); |
|
| 488 |
+ } |
|
| 489 |
+ |
|
| 490 |
+ let SigningDestination::Ipa(output_path) = request.destination else {
|
|
| 491 |
+ unreachable!("app bundle destination returned above");
|
|
| 492 |
+ }; |
|
| 493 |
+ |
|
| 494 |
+ let output_parent = output_path |
|
| 495 |
+ .parent() |
|
| 496 |
+ .filter(|path| !path.as_os_str().is_empty()) |
|
| 497 |
+ .unwrap_or_else(|| Path::new("."));
|
|
| 498 |
+ fs::create_dir_all(output_parent).map_err(|source| BackendError::Io {
|
|
| 499 |
+ action: "Create signed IPA output folder", |
|
| 500 |
+ path: output_parent.to_path_buf(), |
|
| 501 |
+ source, |
|
| 502 |
+ })?; |
|
| 503 |
+ let output_temp = TempBuilder::new() |
|
| 504 |
+ .prefix(".super-sideloader-")
|
|
| 505 |
+ .suffix(".ipa.tmp")
|
|
| 506 |
+ .tempfile_in(output_parent) |
|
| 507 |
+ .map_err(|source| BackendError::Io {
|
|
| 508 |
+ action: "Create temporary signed IPA", |
|
| 509 |
+ path: output_parent.to_path_buf(), |
|
| 510 |
+ source, |
|
| 511 |
+ })? |
|
| 512 |
+ .into_temp_path(); |
|
| 513 |
+ |
|
| 514 |
+ package_ipa(work_dir.path(), output_temp.as_ref(), &mut progress).await?; |
|
| 515 |
+ progress(IpaSigningProgress::Saving); |
|
| 516 |
+ output_temp |
|
| 517 |
+ .persist(&output_path) |
|
| 518 |
+ .map_err(|error| BackendError::Io {
|
|
| 519 |
+ action: "Save signed IPA", |
|
| 520 |
+ path: output_path.clone(), |
|
| 521 |
+ source: error.error, |
|
| 522 |
+ })?; |
|
| 523 |
+ Ok(SigningArtifact::Ipa(output_path)) |
|
| 524 |
+} |
|
| 525 |
+ |
|
| 526 |
+async fn extract_ipa( |
|
| 527 |
+ source_path: &Path, |
|
| 528 |
+ destination: &Path, |
|
| 529 |
+ progress: &mut impl FnMut(IpaSigningProgress), |
|
| 530 |
+) -> BackendResult<()> {
|
|
| 531 |
+ let reader = ExtractionReader::open(source_path).await?; |
|
| 532 |
+ let total = reader.file().entries().len(); |
|
| 533 |
+ progress(IpaSigningProgress::Extracting {
|
|
| 534 |
+ completed: 0, |
|
| 535 |
+ total, |
|
| 536 |
+ }); |
|
| 537 |
+ |
|
| 538 |
+ let mut files = Vec::new(); |
|
| 539 |
+ let mut completed = 0; |
|
| 540 |
+ for index in 0..total {
|
|
| 541 |
+ let entry = &reader.file().entries()[index]; |
|
| 542 |
+ let name = entry_name(entry) |
|
| 543 |
+ .ok_or_else(|| BackendError::Zip("IPA contains an invalid entry name.".to_string()))?
|
|
| 544 |
+ .to_string(); |
|
| 545 |
+ let relative_path = safe_zip_entry_path(&name)?; |
|
| 546 |
+ let output_path = destination.join(&relative_path); |
|
| 547 |
+ let unix_permissions = entry.unix_permissions(); |
|
| 548 |
+ if unix_permissions.is_some_and(is_symlink_mode) {
|
|
| 549 |
+ return Err(BackendError::Zip(format!( |
|
| 550 |
+ "IPA contains unsupported symbolic link entry {name}."
|
|
| 551 |
+ ))); |
|
| 552 |
+ } |
|
| 553 |
+ |
|
| 554 |
+ if name.ends_with('/') {
|
|
| 555 |
+ fs::create_dir_all(&output_path).map_err(|source| BackendError::Io {
|
|
| 556 |
+ action: "Create extracted IPA folder", |
|
| 557 |
+ path: output_path, |
|
| 558 |
+ source, |
|
| 559 |
+ })?; |
|
| 560 |
+ completed += 1; |
|
| 561 |
+ report_extracting_progress(progress, completed, total); |
|
| 562 |
+ continue; |
|
| 563 |
+ } |
|
| 564 |
+ |
|
| 565 |
+ if let Some(parent) = output_path.parent() {
|
|
| 566 |
+ fs::create_dir_all(parent).map_err(|source| BackendError::Io {
|
|
| 567 |
+ action: "Create extracted IPA folder", |
|
| 568 |
+ path: parent.to_path_buf(), |
|
| 569 |
+ source, |
|
| 570 |
+ })?; |
|
| 571 |
+ } |
|
| 572 |
+ files.push(ExtractionPlan {
|
|
| 573 |
+ index, |
|
| 574 |
+ name, |
|
| 575 |
+ output_path, |
|
| 576 |
+ unix_permissions, |
|
| 577 |
+ }); |
|
| 578 |
+ } |
|
| 579 |
+ |
|
| 580 |
+ let mut files = files.into_iter(); |
|
| 581 |
+ let mut workers = JoinSet::new(); |
|
| 582 |
+ let concurrency = archive_worker_count(MAX_EXTRACTION_WORKERS); |
|
| 583 |
+ for _ in 0..concurrency {
|
|
| 584 |
+ let Some(plan) = files.next() else {
|
|
| 585 |
+ break; |
|
| 586 |
+ }; |
|
| 587 |
+ spawn_extraction_worker(&mut workers, reader.clone(), plan); |
|
| 588 |
+ } |
|
| 589 |
+ |
|
| 590 |
+ while let Some(result) = workers.join_next().await {
|
|
| 591 |
+ result.map_err(|error| {
|
|
| 592 |
+ BackendError::Zip(format!("IPA extraction worker failed: {error}"))
|
|
| 593 |
+ })??; |
|
| 594 |
+ completed += 1; |
|
| 595 |
+ report_extracting_progress(progress, completed, total); |
|
| 596 |
+ |
|
| 597 |
+ if let Some(plan) = files.next() {
|
|
| 598 |
+ spawn_extraction_worker(&mut workers, reader.clone(), plan); |
|
| 599 |
+ } |
|
| 600 |
+ } |
|
| 601 |
+ |
|
| 602 |
+ Ok(()) |
|
| 603 |
+} |
|
| 604 |
+ |
|
| 605 |
+struct ExtractionPlan {
|
|
| 606 |
+ index: usize, |
|
| 607 |
+ name: String, |
|
| 608 |
+ output_path: PathBuf, |
|
| 609 |
+ unix_permissions: Option<u16>, |
|
| 610 |
+} |
|
| 611 |
+ |
|
| 612 |
+fn spawn_extraction_worker( |
|
| 613 |
+ workers: &mut JoinSet<BackendResult<()>>, |
|
| 614 |
+ reader: ExtractionReader, |
|
| 615 |
+ plan: ExtractionPlan, |
|
| 616 |
+) {
|
|
| 617 |
+ workers.spawn(async move {
|
|
| 618 |
+ let data = reader.read_entry(plan.index, &plan.name).await?; |
|
| 619 |
+ tokio::fs::write(&plan.output_path, data) |
|
| 620 |
+ .await |
|
| 621 |
+ .map_err(|source| BackendError::Io {
|
|
| 622 |
+ action: "Extract IPA file", |
|
| 623 |
+ path: plan.output_path.clone(), |
|
| 624 |
+ source, |
|
| 625 |
+ })?; |
|
| 626 |
+ set_extracted_permissions(&plan.output_path, plan.unix_permissions) |
|
| 627 |
+ }); |
|
| 628 |
+} |
|
| 629 |
+ |
|
| 630 |
+#[derive(Clone)] |
|
| 631 |
+enum ExtractionReader {
|
|
| 632 |
+ FileSystem(ZipFileReader), |
|
| 633 |
+ Memory(MemoryZipFileReader), |
|
| 634 |
+} |
|
| 635 |
+ |
|
| 636 |
+impl ExtractionReader {
|
|
| 637 |
+ async fn open(path: &Path) -> BackendResult<Self> {
|
|
| 638 |
+ let metadata = tokio::fs::metadata(path) |
|
| 639 |
+ .await |
|
| 640 |
+ .map_err(|source| BackendError::Io {
|
|
| 641 |
+ action: "Inspect IPA before extraction", |
|
| 642 |
+ path: path.to_path_buf(), |
|
| 643 |
+ source, |
|
| 644 |
+ })?; |
|
| 645 |
+ if metadata.len() <= IN_MEMORY_EXTRACTION_LIMIT {
|
|
| 646 |
+ let data = tokio::fs::read(path) |
|
| 647 |
+ .await |
|
| 648 |
+ .map_err(|source| BackendError::Io {
|
|
| 649 |
+ action: "Read IPA for extraction", |
|
| 650 |
+ path: path.to_path_buf(), |
|
| 651 |
+ source, |
|
| 652 |
+ })?; |
|
| 653 |
+ let reader = MemoryZipFileReader::new(data) |
|
| 654 |
+ .await |
|
| 655 |
+ .map_err(|error| BackendError::Zip(format!("{}: {error}", path.display())))?;
|
|
| 656 |
+ Ok(Self::Memory(reader)) |
|
| 657 |
+ } else {
|
|
| 658 |
+ let reader = ZipFileReader::new(path) |
|
| 659 |
+ .await |
|
| 660 |
+ .map_err(|error| BackendError::Zip(format!("{}: {error}", path.display())))?;
|
|
| 661 |
+ Ok(Self::FileSystem(reader)) |
|
| 662 |
+ } |
|
| 663 |
+ } |
|
| 664 |
+ |
|
| 665 |
+ fn file(&self) -> &async_zip::ZipFile {
|
|
| 666 |
+ match self {
|
|
| 667 |
+ Self::FileSystem(reader) => reader.file(), |
|
| 668 |
+ Self::Memory(reader) => reader.file(), |
|
| 669 |
+ } |
|
| 670 |
+ } |
|
| 671 |
+ |
|
| 672 |
+ async fn read_entry(&self, index: usize, label: &str) -> BackendResult<Vec<u8>> {
|
|
| 673 |
+ match self {
|
|
| 674 |
+ Self::FileSystem(reader) => read_entry(reader, index, label).await, |
|
| 675 |
+ Self::Memory(reader) => {
|
|
| 676 |
+ let mut entry_reader = |
|
| 677 |
+ reader.reader_without_entry(index).await.map_err(|error| {
|
|
| 678 |
+ BackendError::Zip(format!("Failed to read {label} from IPA: {error}"))
|
|
| 679 |
+ })?; |
|
| 680 |
+ let mut data = Vec::new(); |
|
| 681 |
+ entry_reader.read_to_end(&mut data).await.map_err(|error| {
|
|
| 682 |
+ BackendError::Zip(format!("Failed to extract {label} from IPA: {error}"))
|
|
| 683 |
+ })?; |
|
| 684 |
+ Ok(data) |
|
| 685 |
+ } |
|
| 686 |
+ } |
|
| 687 |
+ } |
|
| 688 |
+} |
|
| 689 |
+ |
|
| 690 |
+fn archive_worker_count(maximum: usize) -> usize {
|
|
| 691 |
+ std::thread::available_parallelism() |
|
| 692 |
+ .map(|parallelism| parallelism.get()) |
|
| 693 |
+ .unwrap_or(1) |
|
| 694 |
+ .clamp(1, maximum) |
|
| 695 |
+} |
|
| 696 |
+ |
|
| 697 |
+fn safe_zip_entry_path(name: &str) -> BackendResult<PathBuf> {
|
|
| 698 |
+ if name.is_empty() || name.contains('\\') {
|
|
| 699 |
+ return Err(unsafe_zip_path_error(name)); |
|
| 700 |
+ } |
|
| 701 |
+ |
|
| 702 |
+ let mut result = PathBuf::new(); |
|
| 703 |
+ for component in Path::new(name).components() {
|
|
| 704 |
+ match component {
|
|
| 705 |
+ Component::Normal(component) => result.push(component), |
|
| 706 |
+ _ => return Err(unsafe_zip_path_error(name)), |
|
| 707 |
+ } |
|
| 708 |
+ } |
|
| 709 |
+ if result.as_os_str().is_empty() {
|
|
| 710 |
+ Err(unsafe_zip_path_error(name)) |
|
| 711 |
+ } else {
|
|
| 712 |
+ Ok(result) |
|
| 713 |
+ } |
|
| 714 |
+} |
|
| 715 |
+ |
|
| 716 |
+fn unsafe_zip_path_error(name: &str) -> BackendError {
|
|
| 717 |
+ BackendError::Zip(format!("IPA contains unsafe entry path {name:?}."))
|
|
| 718 |
+} |
|
| 719 |
+ |
|
| 720 |
+fn is_symlink_mode(mode: u16) -> bool {
|
|
| 721 |
+ mode & 0o170000 == 0o120000 |
|
| 722 |
+} |
|
| 723 |
+ |
|
| 724 |
+#[cfg(unix)] |
|
| 725 |
+fn set_extracted_permissions(path: &Path, mode: Option<u16>) -> BackendResult<()> {
|
|
| 726 |
+ let Some(mode) = mode else {
|
|
| 727 |
+ return Ok(()); |
|
| 728 |
+ }; |
|
| 729 |
+ let mode = mode & 0o7777; |
|
| 730 |
+ if mode == 0 {
|
|
| 731 |
+ return Ok(()); |
|
| 732 |
+ } |
|
| 733 |
+ fs::set_permissions(path, fs::Permissions::from_mode(u32::from(mode))).map_err(|source| {
|
|
| 734 |
+ BackendError::Io {
|
|
| 735 |
+ action: "Restore extracted IPA permissions", |
|
| 736 |
+ path: path.to_path_buf(), |
|
| 737 |
+ source, |
|
| 738 |
+ } |
|
| 739 |
+ }) |
|
| 740 |
+} |
|
| 741 |
+ |
|
| 742 |
+#[cfg(not(unix))] |
|
| 743 |
+fn set_extracted_permissions(_: &Path, _: Option<u16>) -> BackendResult<()> {
|
|
| 744 |
+ Ok(()) |
|
| 745 |
+} |
|
| 746 |
+ |
|
| 747 |
+fn root_app_bundle(staging_root: &Path) -> BackendResult<PathBuf> {
|
|
| 748 |
+ let payload = staging_root.join("Payload");
|
|
| 749 |
+ let entries = fs::read_dir(&payload).map_err(|source| BackendError::Io {
|
|
| 750 |
+ action: "Read extracted IPA Payload folder", |
|
| 751 |
+ path: payload.clone(), |
|
| 752 |
+ source, |
|
| 753 |
+ })?; |
|
| 754 |
+ let mut apps = entries |
|
| 755 |
+ .filter_map(Result::ok) |
|
| 756 |
+ .filter_map(|entry| {
|
|
| 757 |
+ let path = entry.path(); |
|
| 758 |
+ entry |
|
| 759 |
+ .file_type() |
|
| 760 |
+ .ok() |
|
| 761 |
+ .filter(|kind| kind.is_dir()) |
|
| 762 |
+ .and_then(|_| {
|
|
| 763 |
+ (path.extension().and_then(|value| value.to_str()) == Some("app"))
|
|
| 764 |
+ .then_some(path) |
|
| 765 |
+ }) |
|
| 766 |
+ }) |
|
| 767 |
+ .collect::<Vec<_>>(); |
|
| 768 |
+ apps.sort(); |
|
| 769 |
+ |
|
| 770 |
+ match apps.len() {
|
|
| 771 |
+ 1 => Ok(apps.remove(0)), |
|
| 772 |
+ 0 => Err(BackendError::Zip( |
|
| 773 |
+ "IPA does not contain a root Payload/*.app bundle.".to_string(), |
|
| 774 |
+ )), |
|
| 775 |
+ count => Err(BackendError::Zip(format!( |
|
| 776 |
+ "IPA contains {count} root app bundles; exactly one is required."
|
|
| 777 |
+ ))), |
|
| 778 |
+ } |
|
| 779 |
+} |
|
| 780 |
+ |
|
| 781 |
+fn patch_app_bundle( |
|
| 782 |
+ app_path: &Path, |
|
| 783 |
+ metadata: &AppMetadata, |
|
| 784 |
+ icon_override_path: Option<&Path>, |
|
| 785 |
+) -> BackendResult<()> {
|
|
| 786 |
+ validate_metadata(metadata)?; |
|
| 787 |
+ let info_path = app_path.join("Info.plist");
|
|
| 788 |
+ let mut info = match Value::from_file(&info_path).map_err(|error| {
|
|
| 789 |
+ BackendError::Plist(format!("Failed to read {}: {error}", info_path.display()))
|
|
| 790 |
+ })? {
|
|
| 791 |
+ Value::Dictionary(info) => info, |
|
| 792 |
+ _ => {
|
|
| 793 |
+ return Err(BackendError::Plist(format!( |
|
| 794 |
+ "{} is not a dictionary.",
|
|
| 795 |
+ info_path.display() |
|
| 796 |
+ ))); |
|
| 797 |
+ } |
|
| 798 |
+ }; |
|
| 799 |
+ |
|
| 800 |
+ let old_executable = required_plist_string(&info, "CFBundleExecutable")?.to_string(); |
|
| 801 |
+ validate_executable_name(&old_executable)?; |
|
| 802 |
+ validate_executable_name(&metadata.executable)?; |
|
| 803 |
+ if old_executable != metadata.executable {
|
|
| 804 |
+ let old_path = app_path.join(&old_executable); |
|
| 805 |
+ let new_path = app_path.join(&metadata.executable); |
|
| 806 |
+ if new_path.exists() {
|
|
| 807 |
+ return Err(BackendError::Message(format!( |
|
| 808 |
+ "Cannot rename the app executable to {} because that file already exists.",
|
|
| 809 |
+ metadata.executable |
|
| 810 |
+ ))); |
|
| 811 |
+ } |
|
| 812 |
+ fs::rename(&old_path, &new_path).map_err(|source| BackendError::Io {
|
|
| 813 |
+ action: "Rename app executable", |
|
| 814 |
+ path: old_path, |
|
| 815 |
+ source, |
|
| 816 |
+ })?; |
|
| 817 |
+ } |
|
| 818 |
+ |
|
| 819 |
+ set_info_string(&mut info, "CFBundleIdentifier", &metadata.bundle_id); |
|
| 820 |
+ set_info_string(&mut info, "CFBundleDisplayName", &metadata.name); |
|
| 821 |
+ set_info_string(&mut info, "CFBundleName", &metadata.name); |
|
| 822 |
+ set_info_string(&mut info, "CFBundleShortVersionString", &metadata.version); |
|
| 823 |
+ set_info_string(&mut info, "CFBundleVersion", &metadata.build); |
|
| 824 |
+ set_info_string(&mut info, "CFBundleExecutable", &metadata.executable); |
|
| 825 |
+ set_info_string(&mut info, "MinimumOSVersion", &metadata.minimum_os); |
|
| 826 |
+ if metadata.supported_devices.is_empty() {
|
|
| 827 |
+ info.remove("UIDeviceFamily");
|
|
| 828 |
+ } else {
|
|
| 829 |
+ info.insert( |
|
| 830 |
+ "UIDeviceFamily".to_string(), |
|
| 831 |
+ Value::Array( |
|
| 832 |
+ metadata |
|
| 833 |
+ .supported_devices |
|
| 834 |
+ .iter() |
|
| 835 |
+ .map(|family| Value::Integer(device_family_id(*family).into())) |
|
| 836 |
+ .collect(), |
|
| 837 |
+ ), |
|
| 838 |
+ ); |
|
| 839 |
+ } |
|
| 840 |
+ |
|
| 841 |
+ if let Some(icon_path) = icon_override_path {
|
|
| 842 |
+ patch_icon(app_path, &mut info, icon_path, &metadata.supported_devices)?; |
|
| 843 |
+ } |
|
| 844 |
+ |
|
| 845 |
+ Value::Dictionary(info) |
|
| 846 |
+ .to_file_binary(&info_path) |
|
| 847 |
+ .map_err(|error| {
|
|
| 848 |
+ BackendError::Plist(format!("Failed to write {}: {error}", info_path.display()))
|
|
| 849 |
+ }) |
|
| 850 |
+} |
|
| 851 |
+ |
|
| 852 |
+fn patch_nested_bundle_identifiers( |
|
| 853 |
+ app_path: &Path, |
|
| 854 |
+ replacements: &BTreeMap<String, String>, |
|
| 855 |
+) -> BackendResult<()> {
|
|
| 856 |
+ for entry in WalkDir::new(app_path).follow_links(false) {
|
|
| 857 |
+ let entry = entry.map_err(|error| {
|
|
| 858 |
+ BackendError::Message(format!( |
|
| 859 |
+ "Failed to inspect nested bundles in {}: {error}",
|
|
| 860 |
+ app_path.display() |
|
| 861 |
+ )) |
|
| 862 |
+ })?; |
|
| 863 |
+ let info_path = entry.path(); |
|
| 864 |
+ if !entry.file_type().is_file() || entry.file_name() != "Info.plist" {
|
|
| 865 |
+ continue; |
|
| 866 |
+ } |
|
| 867 |
+ let mut info = match Value::from_file(info_path).map_err(|error| {
|
|
| 868 |
+ BackendError::Plist(format!("Failed to read {}: {error}", info_path.display()))
|
|
| 869 |
+ })? {
|
|
| 870 |
+ Value::Dictionary(info) => info, |
|
| 871 |
+ _ => continue, |
|
| 872 |
+ }; |
|
| 873 |
+ let Some(bundle_id) = plist_string(&info, "CFBundleIdentifier") else {
|
|
| 874 |
+ continue; |
|
| 875 |
+ }; |
|
| 876 |
+ let Some(replacement) = replacements.get(bundle_id) else {
|
|
| 877 |
+ continue; |
|
| 878 |
+ }; |
|
| 879 |
+ if replacement == bundle_id {
|
|
| 880 |
+ continue; |
|
| 881 |
+ } |
|
| 882 |
+ info.insert( |
|
| 883 |
+ "CFBundleIdentifier".to_string(), |
|
| 884 |
+ Value::String(replacement.clone()), |
|
| 885 |
+ ); |
|
| 886 |
+ Value::Dictionary(info) |
|
| 887 |
+ .to_file_binary(info_path) |
|
| 888 |
+ .map_err(|error| {
|
|
| 889 |
+ BackendError::Plist(format!("Failed to write {}: {error}", info_path.display()))
|
|
| 890 |
+ })?; |
|
| 891 |
+ } |
|
| 892 |
+ Ok(()) |
|
| 893 |
+} |
|
| 894 |
+ |
|
| 895 |
+fn validate_metadata(metadata: &AppMetadata) -> BackendResult<()> {
|
|
| 896 |
+ for (label, value) in [ |
|
| 897 |
+ ("app name", metadata.name.as_str()),
|
|
| 898 |
+ ("bundle identifier", metadata.bundle_id.as_str()),
|
|
| 899 |
+ ("version", metadata.version.as_str()),
|
|
| 900 |
+ ("build", metadata.build.as_str()),
|
|
| 901 |
+ ("executable", metadata.executable.as_str()),
|
|
| 902 |
+ ("minimum OS version", metadata.minimum_os.as_str()),
|
|
| 903 |
+ ] {
|
|
| 904 |
+ if value.trim().is_empty() {
|
|
| 905 |
+ return Err(BackendError::Message(format!( |
|
| 906 |
+ "The {label} cannot be empty when signing."
|
|
| 907 |
+ ))); |
|
| 908 |
+ } |
|
| 909 |
+ } |
|
| 910 |
+ Ok(()) |
|
| 911 |
+} |
|
| 912 |
+ |
|
| 913 |
+fn validate_executable_name(name: &str) -> BackendResult<()> {
|
|
| 914 |
+ let mut components = Path::new(name).components(); |
|
| 915 |
+ if matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() {
|
|
| 916 |
+ Ok(()) |
|
| 917 |
+ } else {
|
|
| 918 |
+ Err(BackendError::Message(format!( |
|
| 919 |
+ "The app executable name {name:?} must be a file name, not a path."
|
|
| 920 |
+ ))) |
|
| 921 |
+ } |
|
| 922 |
+} |
|
| 923 |
+ |
|
| 924 |
+fn set_info_string(info: &mut Dictionary, key: &str, value: &str) {
|
|
| 925 |
+ info.insert(key.to_string(), Value::String(value.to_string())); |
|
| 926 |
+} |
|
| 927 |
+ |
|
| 928 |
+fn device_family_id(family: SupportedDeviceFamily) -> i64 {
|
|
| 929 |
+ match family {
|
|
| 930 |
+ SupportedDeviceFamily::IPhone => 1, |
|
| 931 |
+ SupportedDeviceFamily::IPad => 2, |
|
| 932 |
+ SupportedDeviceFamily::AppleTv => 3, |
|
| 933 |
+ SupportedDeviceFamily::AppleWatch => 4, |
|
| 934 |
+ SupportedDeviceFamily::Mac => 6, |
|
| 935 |
+ } |
|
| 936 |
+} |
|
| 937 |
+ |
|
| 938 |
+fn patch_icon( |
|
| 939 |
+ app_path: &Path, |
|
| 940 |
+ info: &mut Dictionary, |
|
| 941 |
+ icon_path: &Path, |
|
| 942 |
+ supported_devices: &[SupportedDeviceFamily], |
|
| 943 |
+) -> BackendResult<()> {
|
|
| 944 |
+ if !icon_path.is_file() {
|
|
| 945 |
+ return Err(BackendError::Message(format!( |
|
| 946 |
+ "The app icon override does not exist: {}",
|
|
| 947 |
+ icon_path.display() |
|
| 948 |
+ ))); |
|
| 949 |
+ } |
|
| 950 |
+ if !icon_path |
|
| 951 |
+ .extension() |
|
| 952 |
+ .and_then(|value| value.to_str()) |
|
| 953 |
+ .is_some_and(|extension| extension.eq_ignore_ascii_case("png"))
|
|
| 954 |
+ {
|
|
| 955 |
+ return Err(BackendError::Message( |
|
| 956 |
+ "The app icon override must be a PNG file.".to_string(), |
|
| 957 |
+ )); |
|
| 958 |
+ } |
|
| 959 |
+ |
|
| 960 |
+ write_icon_variants(app_path, icon_path)?; |
|
| 961 |
+ |
|
| 962 |
+ let supports_ipad = supported_devices.contains(&SupportedDeviceFamily::IPad); |
|
| 963 |
+ let supports_iphone = supported_devices.contains(&SupportedDeviceFamily::IPhone); |
|
| 964 |
+ let (primary_icon_name, primary_icon_names) = if supports_iphone || !supports_ipad {
|
|
| 965 |
+ (IPHONE_PRIMARY_ICON_NAME, IPHONE_ICON_NAMES) |
|
| 966 |
+ } else {
|
|
| 967 |
+ (IPAD_PRIMARY_ICON_NAME, IPAD_ICON_NAMES) |
|
| 968 |
+ }; |
|
| 969 |
+ info.remove("CFBundleIconName");
|
|
| 970 |
+ info.insert( |
|
| 971 |
+ "CFBundleIconFile".to_string(), |
|
| 972 |
+ Value::String(primary_icon_name.to_string()), |
|
| 973 |
+ ); |
|
| 974 |
+ info.insert( |
|
| 975 |
+ "CFBundleIconFiles".to_string(), |
|
| 976 |
+ icon_file_names(primary_icon_names), |
|
| 977 |
+ ); |
|
| 978 |
+ info.insert( |
|
| 979 |
+ "CFBundleIcons".to_string(), |
|
| 980 |
+ Value::Dictionary(icon_dictionary(primary_icon_names)), |
|
| 981 |
+ ); |
|
| 982 |
+ if supports_ipad {
|
|
| 983 |
+ info.insert( |
|
| 984 |
+ "CFBundleIconFiles~ipad".to_string(), |
|
| 985 |
+ icon_file_names(IPAD_ICON_NAMES), |
|
| 986 |
+ ); |
|
| 987 |
+ info.insert( |
|
| 988 |
+ "CFBundleIcons~ipad".to_string(), |
|
| 989 |
+ Value::Dictionary(icon_dictionary(IPAD_ICON_NAMES)), |
|
| 990 |
+ ); |
|
| 991 |
+ } else {
|
|
| 992 |
+ info.remove("CFBundleIconFiles~ipad");
|
|
| 993 |
+ info.remove("CFBundleIcons~ipad");
|
|
| 994 |
+ } |
|
| 995 |
+ Ok(()) |
|
| 996 |
+} |
|
| 997 |
+ |
|
| 998 |
+fn icon_file_names(names: &[&str]) -> Value {
|
|
| 999 |
+ Value::Array( |
|
| 1000 |
+ names |
|
| 1001 |
+ .iter() |
|
| 1002 |
+ .map(|name| Value::String((*name).to_string())) |
|
| 1003 |
+ .collect(), |
|
| 1004 |
+ ) |
|
| 1005 |
+} |
|
| 1006 |
+ |
|
| 1007 |
+fn icon_dictionary(names: &[&str]) -> Dictionary {
|
|
| 1008 |
+ let mut primary = Dictionary::new(); |
|
| 1009 |
+ primary.insert("CFBundleIconFiles".to_string(), icon_file_names(names));
|
|
| 1010 |
+ let mut icons = Dictionary::new(); |
|
| 1011 |
+ icons.insert( |
|
| 1012 |
+ "CFBundlePrimaryIcon".to_string(), |
|
| 1013 |
+ Value::Dictionary(primary), |
|
| 1014 |
+ ); |
|
| 1015 |
+ icons |
|
| 1016 |
+} |
|
| 1017 |
+ |
|
| 1018 |
+fn sign_staged_app(app_path: &Path, request: &IpaSigningRequest) -> BackendResult<()> {
|
|
| 1019 |
+ let root_profile_data = request |
|
| 1020 |
+ .provisioning_profiles |
|
| 1021 |
+ .get(&request.metadata.bundle_id) |
|
| 1022 |
+ .ok_or_else(|| {
|
|
| 1023 |
+ BackendError::Message(format!( |
|
| 1024 |
+ "No provisioning profile was downloaded for {}.",
|
|
| 1025 |
+ request.metadata.bundle_id |
|
| 1026 |
+ )) |
|
| 1027 |
+ })?; |
|
| 1028 |
+ let root_profile = ProvisioningProfile::parse(root_profile_data).map_err(|error| {
|
|
| 1029 |
+ BackendError::Message(format!( |
|
| 1030 |
+ "Failed to parse the downloaded provisioning profile: {error}"
|
|
| 1031 |
+ )) |
|
| 1032 |
+ })?; |
|
| 1033 |
+ validate_signing_profile(&root_profile, &request.metadata.bundle_id, request)?; |
|
| 1034 |
+ |
|
| 1035 |
+ let private_key_der = decode_private_key(&request.private_key_pem)?; |
|
| 1036 |
+ let signer = RustCryptoCmsSigner::from_der( |
|
| 1037 |
+ &private_key_der, |
|
| 1038 |
+ &request.certificate_der, |
|
| 1039 |
+ root_profile |
|
| 1040 |
+ .certificate_chain_der() |
|
| 1041 |
+ .iter() |
|
| 1042 |
+ .map(Vec::as_slice), |
|
| 1043 |
+ ) |
|
| 1044 |
+ .map_err(|error| {
|
|
| 1045 |
+ BackendError::Message(format!("Failed to prepare the code signer: {error}"))
|
|
| 1046 |
+ })?; |
|
| 1047 |
+ let mut settings = BundleSigningSettings::new( |
|
| 1048 |
+ request.team_id.clone(), |
|
| 1049 |
+ root_profile.entitlements().clone(), |
|
| 1050 |
+ Some(&signer), |
|
| 1051 |
+ ); |
|
| 1052 |
+ settings.embedded_mobileprovisions_by_bundle_id = request |
|
| 1053 |
+ .provisioning_profiles |
|
| 1054 |
+ .iter() |
|
| 1055 |
+ .map(|(bundle_id, data)| (bundle_id.clone(), data.as_slice())) |
|
| 1056 |
+ .collect(); |
|
| 1057 |
+ for (bundle_id, data) in &request.provisioning_profiles {
|
|
| 1058 |
+ if bundle_id == &request.metadata.bundle_id {
|
|
| 1059 |
+ continue; |
|
| 1060 |
+ } |
|
| 1061 |
+ let profile = ProvisioningProfile::parse(data).map_err(|error| {
|
|
| 1062 |
+ BackendError::Message(format!( |
|
| 1063 |
+ "Failed to parse the provisioning profile for {bundle_id}: {error}"
|
|
| 1064 |
+ )) |
|
| 1065 |
+ })?; |
|
| 1066 |
+ validate_signing_profile(&profile, bundle_id, request)?; |
|
| 1067 |
+ settings |
|
| 1068 |
+ .entitlements_by_bundle_id |
|
| 1069 |
+ .insert(bundle_id.clone(), profile.entitlements().clone()); |
|
| 1070 |
+ } |
|
| 1071 |
+ sign_bundle(app_path, &settings) |
|
| 1072 |
+ .map_err(|error| BackendError::Message(format!("Failed to sign the app bundle: {error}")))
|
|
| 1073 |
+} |
|
| 1074 |
+ |
|
| 1075 |
+fn validate_signing_profile( |
|
| 1076 |
+ profile: &ProvisioningProfile, |
|
| 1077 |
+ bundle_id: &str, |
|
| 1078 |
+ request: &IpaSigningRequest, |
|
| 1079 |
+) -> BackendResult<()> {
|
|
| 1080 |
+ if profile.team_id() != request.team_id {
|
|
| 1081 |
+ return Err(BackendError::Message(format!( |
|
| 1082 |
+ "The provisioning profile for {bundle_id} belongs to team {}, not selected team {}.",
|
|
| 1083 |
+ profile.team_id(), |
|
| 1084 |
+ request.team_id |
|
| 1085 |
+ ))); |
|
| 1086 |
+ } |
|
| 1087 |
+ if !profile_contains_certificate(profile, &request.certificate_der) {
|
|
| 1088 |
+ return Err(BackendError::Message(format!( |
|
| 1089 |
+ "The provisioning profile for {bundle_id} does not include the selected certificate. Refresh Developer Settings and try again."
|
|
| 1090 |
+ ))); |
|
| 1091 |
+ } |
|
| 1092 |
+ |
|
| 1093 |
+ let expected_identifier = if bundle_id |
|
| 1094 |
+ .strip_prefix(&request.team_id) |
|
| 1095 |
+ .is_some_and(|suffix| suffix.starts_with('.'))
|
|
| 1096 |
+ {
|
|
| 1097 |
+ bundle_id.to_string() |
|
| 1098 |
+ } else {
|
|
| 1099 |
+ format!("{}.{bundle_id}", request.team_id)
|
|
| 1100 |
+ }; |
|
| 1101 |
+ let profile_identifier = profile |
|
| 1102 |
+ .entitlements() |
|
| 1103 |
+ .get("application-identifier")
|
|
| 1104 |
+ .and_then(Value::as_string) |
|
| 1105 |
+ .ok_or_else(|| {
|
|
| 1106 |
+ BackendError::Message(format!( |
|
| 1107 |
+ "The provisioning profile for {bundle_id} has no application identifier."
|
|
| 1108 |
+ )) |
|
| 1109 |
+ })?; |
|
| 1110 |
+ if profile_identifier != expected_identifier |
|
| 1111 |
+ && !profile_identifier |
|
| 1112 |
+ .strip_suffix('*')
|
|
| 1113 |
+ .is_some_and(|prefix| expected_identifier.starts_with(prefix)) |
|
| 1114 |
+ {
|
|
| 1115 |
+ return Err(BackendError::Message(format!( |
|
| 1116 |
+ "Provisioning profile {profile_identifier} does not match bundle {bundle_id}."
|
|
| 1117 |
+ ))); |
|
| 1118 |
+ } |
|
| 1119 |
+ Ok(()) |
|
| 1120 |
+} |
|
| 1121 |
+ |
|
| 1122 |
+fn profile_contains_certificate(profile: &ProvisioningProfile, certificate_der: &[u8]) -> bool {
|
|
| 1123 |
+ profile |
|
| 1124 |
+ .plist() |
|
| 1125 |
+ .get("DeveloperCertificates")
|
|
| 1126 |
+ .and_then(Value::as_array) |
|
| 1127 |
+ .is_some_and(|certificates| {
|
|
| 1128 |
+ certificates |
|
| 1129 |
+ .iter() |
|
| 1130 |
+ .filter_map(Value::as_data) |
|
| 1131 |
+ .any(|candidate| candidate == certificate_der) |
|
| 1132 |
+ }) |
|
| 1133 |
+} |
|
| 1134 |
+ |
|
| 1135 |
+fn decode_private_key(data: &[u8]) -> BackendResult<Vec<u8>> {
|
|
| 1136 |
+ if !data.starts_with(b"-----BEGIN ") {
|
|
| 1137 |
+ return Ok(data.to_vec()); |
|
| 1138 |
+ } |
|
| 1139 |
+ let (label, der) = pem_rfc7468::decode_vec(data).map_err(|error| {
|
|
| 1140 |
+ BackendError::Message(format!( |
|
| 1141 |
+ "Failed to decode the managed PEM private key: {error}"
|
|
| 1142 |
+ )) |
|
| 1143 |
+ })?; |
|
| 1144 |
+ if matches!(label, "PRIVATE KEY" | "RSA PRIVATE KEY") {
|
|
| 1145 |
+ Ok(der) |
|
| 1146 |
+ } else {
|
|
| 1147 |
+ Err(BackendError::Message(format!( |
|
| 1148 |
+ "Unsupported managed private key PEM label {label:?}."
|
|
| 1149 |
+ ))) |
|
| 1150 |
+ } |
|
| 1151 |
+} |
|
| 1152 |
+ |
|
| 1153 |
+async fn package_ipa( |
|
| 1154 |
+ staging_root: &Path, |
|
| 1155 |
+ output_path: &Path, |
|
| 1156 |
+ progress: &mut impl FnMut(IpaSigningProgress), |
|
| 1157 |
+) -> BackendResult<()> {
|
|
| 1158 |
+ let mut entries = WalkDir::new(staging_root) |
|
| 1159 |
+ .follow_links(false) |
|
| 1160 |
+ .into_iter() |
|
| 1161 |
+ .skip(1) |
|
| 1162 |
+ .map(|entry| {
|
|
| 1163 |
+ entry.map_err(|error| {
|
|
| 1164 |
+ BackendError::Zip(format!( |
|
| 1165 |
+ "Failed to enumerate staged IPA files under {}: {error}",
|
|
| 1166 |
+ staging_root.display() |
|
| 1167 |
+ )) |
|
| 1168 |
+ }) |
|
| 1169 |
+ }) |
|
| 1170 |
+ .collect::<BackendResult<Vec<_>>>()?; |
|
| 1171 |
+ entries.sort_by_key(|entry| entry.path().to_path_buf()); |
|
| 1172 |
+ |
|
| 1173 |
+ let output = tokio::fs::File::create(output_path) |
|
| 1174 |
+ .await |
|
| 1175 |
+ .map_err(|source| BackendError::Io {
|
|
| 1176 |
+ action: "Create signed IPA archive", |
|
| 1177 |
+ path: output_path.to_path_buf(), |
|
| 1178 |
+ source, |
|
| 1179 |
+ })?; |
|
| 1180 |
+ let mut writer = ZipFileWriter::with_tokio(output); |
|
| 1181 |
+ let total = entries.len(); |
|
| 1182 |
+ progress(IpaSigningProgress::Packaging {
|
|
| 1183 |
+ completed: 0, |
|
| 1184 |
+ total, |
|
| 1185 |
+ }); |
|
| 1186 |
+ |
|
| 1187 |
+ let mut directories = Vec::new(); |
|
| 1188 |
+ let mut files = Vec::new(); |
|
| 1189 |
+ for entry in entries {
|
|
| 1190 |
+ let path = entry.path(); |
|
| 1191 |
+ let relative = path.strip_prefix(staging_root).map_err(|_| {
|
|
| 1192 |
+ BackendError::Zip(format!( |
|
| 1193 |
+ "Failed to create a relative path for {}.",
|
|
| 1194 |
+ path.display() |
|
| 1195 |
+ )) |
|
| 1196 |
+ })?; |
|
| 1197 |
+ let mut name = relative |
|
| 1198 |
+ .to_string_lossy() |
|
| 1199 |
+ .replace(std::path::MAIN_SEPARATOR, "/"); |
|
| 1200 |
+ let metadata = entry.metadata().map_err(|error| {
|
|
| 1201 |
+ BackendError::Zip(format!("Failed to inspect {}: {error}", path.display()))
|
|
| 1202 |
+ })?; |
|
| 1203 |
+ if metadata.file_type().is_symlink() {
|
|
| 1204 |
+ return Err(BackendError::Zip(format!( |
|
| 1205 |
+ "Staged IPA contains unsupported symbolic link {}.",
|
|
| 1206 |
+ path.display() |
|
| 1207 |
+ ))); |
|
| 1208 |
+ } |
|
| 1209 |
+ |
|
| 1210 |
+ if metadata.is_dir() {
|
|
| 1211 |
+ name.push('/');
|
|
| 1212 |
+ directories.push(PackageDirectory {
|
|
| 1213 |
+ path: path.to_path_buf(), |
|
| 1214 |
+ name, |
|
| 1215 |
+ unix_mode: zip_mode(&metadata, true), |
|
| 1216 |
+ }); |
|
| 1217 |
+ } else if metadata.is_file() {
|
|
| 1218 |
+ files.push(PackageFilePlan {
|
|
| 1219 |
+ path: path.to_path_buf(), |
|
| 1220 |
+ name, |
|
| 1221 |
+ unix_mode: zip_mode(&metadata, false), |
|
| 1222 |
+ }); |
|
| 1223 |
+ } |
|
| 1224 |
+ } |
|
| 1225 |
+ |
|
| 1226 |
+ let mut completed = 0; |
|
| 1227 |
+ for directory in directories {
|
|
| 1228 |
+ let builder = ZipEntryBuilder::new(directory.name.into(), Compression::Stored) |
|
| 1229 |
+ .unix_permissions(directory.unix_mode); |
|
| 1230 |
+ writer |
|
| 1231 |
+ .write_entry_whole(builder, &[]) |
|
| 1232 |
+ .await |
|
| 1233 |
+ .map_err(|error| {
|
|
| 1234 |
+ BackendError::Zip(format!("Failed to package {:?}: {error}", directory.path))
|
|
| 1235 |
+ })?; |
|
| 1236 |
+ completed += 1; |
|
| 1237 |
+ report_packaging_progress(progress, completed, total); |
|
| 1238 |
+ } |
|
| 1239 |
+ |
|
| 1240 |
+ let mut files = files.into_iter(); |
|
| 1241 |
+ let mut workers = JoinSet::new(); |
|
| 1242 |
+ let concurrency = archive_worker_count(MAX_PACKAGING_WORKERS); |
|
| 1243 |
+ for _ in 0..concurrency {
|
|
| 1244 |
+ let Some(plan) = files.next() else {
|
|
| 1245 |
+ break; |
|
| 1246 |
+ }; |
|
| 1247 |
+ workers.spawn_blocking(move || prepare_zip_file(plan)); |
|
| 1248 |
+ } |
|
| 1249 |
+ |
|
| 1250 |
+ while let Some(result) = workers.join_next().await {
|
|
| 1251 |
+ let prepared = result.map_err(|error| {
|
|
| 1252 |
+ BackendError::Zip(format!("IPA packaging worker failed: {error}"))
|
|
| 1253 |
+ })??; |
|
| 1254 |
+ writer |
|
| 1255 |
+ .write_entry_whole_precompressed(prepared.entry, &prepared.data) |
|
| 1256 |
+ .await |
|
| 1257 |
+ .map_err(|error| {
|
|
| 1258 |
+ BackendError::Zip(format!( |
|
| 1259 |
+ "Failed to package {:?}: {error}",
|
|
| 1260 |
+ prepared.source_path |
|
| 1261 |
+ )) |
|
| 1262 |
+ })?; |
|
| 1263 |
+ completed += 1; |
|
| 1264 |
+ report_packaging_progress(progress, completed, total); |
|
| 1265 |
+ |
|
| 1266 |
+ if let Some(plan) = files.next() {
|
|
| 1267 |
+ workers.spawn_blocking(move || prepare_zip_file(plan)); |
|
| 1268 |
+ } |
|
| 1269 |
+ } |
|
| 1270 |
+ |
|
| 1271 |
+ let mut output = writer |
|
| 1272 |
+ .close() |
|
| 1273 |
+ .await |
|
| 1274 |
+ .map_err(|error| BackendError::Zip(format!("Failed to finish signed IPA: {error}")))?
|
|
| 1275 |
+ .into_inner(); |
|
| 1276 |
+ output.flush().await.map_err(|source| BackendError::Io {
|
|
| 1277 |
+ action: "Flush signed IPA archive", |
|
| 1278 |
+ path: output_path.to_path_buf(), |
|
| 1279 |
+ source, |
|
| 1280 |
+ })?; |
|
| 1281 |
+ output.sync_all().await.map_err(|source| BackendError::Io {
|
|
| 1282 |
+ action: "Sync signed IPA archive", |
|
| 1283 |
+ path: output_path.to_path_buf(), |
|
| 1284 |
+ source, |
|
| 1285 |
+ }) |
|
| 1286 |
+} |
|
| 1287 |
+ |
|
| 1288 |
+struct PackageDirectory {
|
|
| 1289 |
+ path: PathBuf, |
|
| 1290 |
+ name: String, |
|
| 1291 |
+ unix_mode: u16, |
|
| 1292 |
+} |
|
| 1293 |
+ |
|
| 1294 |
+struct PackageFilePlan {
|
|
| 1295 |
+ path: PathBuf, |
|
| 1296 |
+ name: String, |
|
| 1297 |
+ unix_mode: u16, |
|
| 1298 |
+} |
|
| 1299 |
+ |
|
| 1300 |
+struct PreparedZipFile {
|
|
| 1301 |
+ source_path: PathBuf, |
|
| 1302 |
+ entry: ZipEntry, |
|
| 1303 |
+ data: Vec<u8>, |
|
| 1304 |
+} |
|
| 1305 |
+ |
|
| 1306 |
+fn prepare_zip_file(plan: PackageFilePlan) -> BackendResult<PreparedZipFile> {
|
|
| 1307 |
+ let source_data = fs::read(&plan.path).map_err(|source| BackendError::Io {
|
|
| 1308 |
+ action: "Read staged IPA file", |
|
| 1309 |
+ path: plan.path.clone(), |
|
| 1310 |
+ source, |
|
| 1311 |
+ })?; |
|
| 1312 |
+ let source_size = source_data.len() as u64; |
|
| 1313 |
+ let crc32 = crc32fast::hash(&source_data); |
|
| 1314 |
+ let preferred_compression = compression_for_path(&plan.path); |
|
| 1315 |
+ let (compression, data) = if preferred_compression == Compression::Deflate {
|
|
| 1316 |
+ let mut encoder = |
|
| 1317 |
+ DeflateEncoder::new(Vec::new(), FlateCompression::new(FAST_DEFLATE_LEVEL)); |
|
| 1318 |
+ encoder |
|
| 1319 |
+ .write_all(&source_data) |
|
| 1320 |
+ .map_err(|source| BackendError::Io {
|
|
| 1321 |
+ action: "Compress staged IPA file", |
|
| 1322 |
+ path: plan.path.clone(), |
|
| 1323 |
+ source, |
|
| 1324 |
+ })?; |
|
| 1325 |
+ let compressed = encoder.finish().map_err(|source| BackendError::Io {
|
|
| 1326 |
+ action: "Finish compressing staged IPA file", |
|
| 1327 |
+ path: plan.path.clone(), |
|
| 1328 |
+ source, |
|
| 1329 |
+ })?; |
|
| 1330 |
+ if compressed.len() < source_data.len() {
|
|
| 1331 |
+ (Compression::Deflate, compressed) |
|
| 1332 |
+ } else {
|
|
| 1333 |
+ (Compression::Stored, source_data) |
|
| 1334 |
+ } |
|
| 1335 |
+ } else {
|
|
| 1336 |
+ (Compression::Stored, source_data) |
|
| 1337 |
+ }; |
|
| 1338 |
+ let compressed_size = data.len() as u64; |
|
| 1339 |
+ let entry = ZipEntryBuilder::new(plan.name.into(), compression) |
|
| 1340 |
+ .unix_permissions(plan.unix_mode) |
|
| 1341 |
+ .crc32(crc32) |
|
| 1342 |
+ .uncompressed_size(source_size) |
|
| 1343 |
+ .compressed_size(compressed_size) |
|
| 1344 |
+ .build(); |
|
| 1345 |
+ |
|
| 1346 |
+ Ok(PreparedZipFile {
|
|
| 1347 |
+ source_path: plan.path, |
|
| 1348 |
+ entry, |
|
| 1349 |
+ data, |
|
| 1350 |
+ }) |
|
| 1351 |
+} |
|
| 1352 |
+ |
|
| 1353 |
+fn compression_for_path(path: &Path) -> Compression {
|
|
| 1354 |
+ let already_compressed = path |
|
| 1355 |
+ .extension() |
|
| 1356 |
+ .and_then(|extension| extension.to_str()) |
|
| 1357 |
+ .is_some_and(|extension| {
|
|
| 1358 |
+ matches!( |
|
| 1359 |
+ extension.to_ascii_lowercase().as_str(), |
|
| 1360 |
+ "7z" | "aac" |
|
| 1361 |
+ | "bz2" |
|
| 1362 |
+ | "car" |
|
| 1363 |
+ | "gif" |
|
| 1364 |
+ | "gz" |
|
| 1365 |
+ | "heic" |
|
| 1366 |
+ | "heif" |
|
| 1367 |
+ | "ipa" |
|
| 1368 |
+ | "jpeg" |
|
| 1369 |
+ | "jpg" |
|
| 1370 |
+ | "m4a" |
|
| 1371 |
+ | "m4v" |
|
| 1372 |
+ | "mov" |
|
| 1373 |
+ | "mp3" |
|
| 1374 |
+ | "mp4" |
|
| 1375 |
+ | "pdf" |
|
| 1376 |
+ | "png" |
|
| 1377 |
+ | "rar" |
|
| 1378 |
+ | "webp" |
|
| 1379 |
+ | "xz" |
|
| 1380 |
+ | "zip" |
|
| 1381 |
+ | "zst" |
|
| 1382 |
+ ) |
|
| 1383 |
+ }); |
|
| 1384 |
+ if already_compressed {
|
|
| 1385 |
+ Compression::Stored |
|
| 1386 |
+ } else {
|
|
| 1387 |
+ Compression::Deflate |
|
| 1388 |
+ } |
|
| 1389 |
+} |
|
| 1390 |
+ |
|
| 1391 |
+fn report_extracting_progress( |
|
| 1392 |
+ progress: &mut impl FnMut(IpaSigningProgress), |
|
| 1393 |
+ completed: usize, |
|
| 1394 |
+ total: usize, |
|
| 1395 |
+) {
|
|
| 1396 |
+ if should_report_item_progress(completed, total) {
|
|
| 1397 |
+ progress(IpaSigningProgress::Extracting { completed, total });
|
|
| 1398 |
+ } |
|
| 1399 |
+} |
|
| 1400 |
+ |
|
| 1401 |
+fn report_packaging_progress( |
|
| 1402 |
+ progress: &mut impl FnMut(IpaSigningProgress), |
|
| 1403 |
+ completed: usize, |
|
| 1404 |
+ total: usize, |
|
| 1405 |
+) {
|
|
| 1406 |
+ if should_report_item_progress(completed, total) {
|
|
| 1407 |
+ progress(IpaSigningProgress::Packaging { completed, total });
|
|
| 1408 |
+ } |
|
| 1409 |
+} |
|
| 1410 |
+ |
|
| 1411 |
+fn should_report_item_progress(completed: usize, total: usize) -> bool {
|
|
| 1412 |
+ completed == 0 |
|
| 1413 |
+ || completed >= total |
|
| 1414 |
+ || (completed.saturating_mul(100) / total.max(1)) |
|
| 1415 |
+ != (completed.saturating_sub(1).saturating_mul(100) / total.max(1)) |
|
| 1416 |
+} |
|
| 1417 |
+ |
|
| 1418 |
+#[cfg(unix)] |
|
| 1419 |
+fn zip_mode(metadata: &fs::Metadata, _: bool) -> u16 {
|
|
| 1420 |
+ (metadata.permissions().mode() & 0xffff) as u16 |
|
| 1421 |
+} |
|
| 1422 |
+ |
|
| 1423 |
+#[cfg(not(unix))] |
|
| 1424 |
+fn zip_mode(_: &fs::Metadata, directory: bool) -> u16 {
|
|
| 1425 |
+ if directory {
|
|
| 1426 |
+ 0o755 |
|
| 1427 |
+ } else {
|
|
| 1428 |
+ 0o644 |
|
| 1429 |
+ } |
|
| 1430 |
+} |
|
| 1431 |
+ |
|
| 311 | 1432 |
#[cfg(test)] |
| 312 | 1433 |
mod tests {
|
| 313 | 1434 |
use super::*; |
| 314 | 1435 |
|
| 1436 |
+ fn signing_metadata(executable: &str) -> AppMetadata {
|
|
| 1437 |
+ AppMetadata {
|
|
| 1438 |
+ name: "Patched App".to_string(), |
|
| 1439 |
+ bundle_id: "com.example.patched".to_string(), |
|
| 1440 |
+ version: "2.0".to_string(), |
|
| 1441 |
+ build: "42".to_string(), |
|
| 1442 |
+ executable: executable.to_string(), |
|
| 1443 |
+ minimum_os: "16.0".to_string(), |
|
| 1444 |
+ supported_devices: vec![SupportedDeviceFamily::IPhone, SupportedDeviceFamily::IPad], |
|
| 1445 |
+ } |
|
| 1446 |
+ } |
|
| 1447 |
+ |
|
| 1448 |
+ fn write_test_app(app_path: &Path, executable: &str) {
|
|
| 1449 |
+ fs::create_dir_all(app_path).unwrap(); |
|
| 1450 |
+ fs::write(app_path.join(executable), b"executable").unwrap(); |
|
| 1451 |
+ let mut info = Dictionary::new(); |
|
| 1452 |
+ info.insert( |
|
| 1453 |
+ "CFBundleIdentifier".to_string(), |
|
| 1454 |
+ Value::String("com.example.original".to_string()),
|
|
| 1455 |
+ ); |
|
| 1456 |
+ info.insert( |
|
| 1457 |
+ "CFBundleExecutable".to_string(), |
|
| 1458 |
+ Value::String(executable.to_string()), |
|
| 1459 |
+ ); |
|
| 1460 |
+ Value::Dictionary(info) |
|
| 1461 |
+ .to_file_binary(app_path.join("Info.plist"))
|
|
| 1462 |
+ .unwrap(); |
|
| 1463 |
+ } |
|
| 1464 |
+ |
|
| 315 | 1465 |
#[test] |
| 316 | 1466 |
fn info_plist_paths_require_payload_app_prefix() {
|
| 317 | 1467 |
assert!(is_app_info_plist("Payload/App.app/Info.plist"));
|
| 318 |
- assert!(is_app_info_plist("Payload/Nested/App.app/Info.plist"));
|
|
| 1468 |
+ assert!(!is_app_info_plist("Payload/Nested/App.app/Info.plist"));
|
|
| 319 | 1469 |
assert!(!is_app_info_plist("App.app/Info.plist"));
|
| 320 | 1470 |
assert!(!is_app_info_plist("Payload/App.app/Other.plist"));
|
| 321 | 1471 |
} |
| 322 | 1472 |
|
| 1473 |
+ #[test] |
|
| 1474 |
+ fn nested_signable_bundle_paths_include_extensions_and_nested_apps() {
|
|
| 1475 |
+ let root = "Payload/App.app/"; |
|
| 1476 |
+ |
|
| 1477 |
+ assert!(is_nested_signable_info_plist( |
|
| 1478 |
+ "Payload/App.app/PlugIns/Widget.appex/Info.plist", |
|
| 1479 |
+ root |
|
| 1480 |
+ )); |
|
| 1481 |
+ assert!(is_nested_signable_info_plist( |
|
| 1482 |
+ "Payload/App.app/Watch/WatchApp.app/Info.plist", |
|
| 1483 |
+ root |
|
| 1484 |
+ )); |
|
| 1485 |
+ assert!(!is_nested_signable_info_plist( |
|
| 1486 |
+ "Payload/App.app/Frameworks/Example.framework/Info.plist", |
|
| 1487 |
+ root |
|
| 1488 |
+ )); |
|
| 1489 |
+ assert!(!is_nested_signable_info_plist( |
|
| 1490 |
+ "Payload/App.app/Info.plist", |
|
| 1491 |
+ root |
|
| 1492 |
+ )); |
|
| 1493 |
+ } |
|
| 1494 |
+ |
|
| 323 | 1495 |
#[test] |
| 324 | 1496 |
fn required_plist_string_reports_missing_key() {
|
| 325 | 1497 |
let mut info = Dictionary::new(); |
| ... | ... |
@@ -340,6 +1512,24 @@ mod tests {
|
| 340 | 1512 |
); |
| 341 | 1513 |
} |
| 342 | 1514 |
|
| 1515 |
+ #[test] |
|
| 1516 |
+ fn item_progress_is_sampled_at_percent_boundaries_and_completion() {
|
|
| 1517 |
+ assert!(should_report_item_progress(0, 1_000)); |
|
| 1518 |
+ assert!(!should_report_item_progress(1, 1_000)); |
|
| 1519 |
+ assert!(should_report_item_progress(10, 1_000)); |
|
| 1520 |
+ assert!(should_report_item_progress(1_000, 1_000)); |
|
| 1521 |
+ } |
|
| 1522 |
+ |
|
| 1523 |
+ #[test] |
|
| 1524 |
+ fn packaging_skips_deflate_for_already_compressed_formats() {
|
|
| 1525 |
+ for path in ["Icon.PNG", "Assets.car", "Video.mp4", "Nested/archive.zip"] {
|
|
| 1526 |
+ assert_eq!(compression_for_path(Path::new(path)), Compression::Stored); |
|
| 1527 |
+ } |
|
| 1528 |
+ for path in ["Info.plist", "Executable", "Framework.dylib"] {
|
|
| 1529 |
+ assert_eq!(compression_for_path(Path::new(path)), Compression::Deflate); |
|
| 1530 |
+ } |
|
| 1531 |
+ } |
|
| 1532 |
+ |
|
| 343 | 1533 |
#[test] |
| 344 | 1534 |
fn supported_device_family_values_are_normalized() {
|
| 345 | 1535 |
let mut info = Dictionary::new(); |
| ... | ... |
@@ -379,4 +1569,241 @@ mod tests {
|
| 379 | 1569 |
&& entitlement.value == EntitlementValue::Boolean(true) |
| 380 | 1570 |
})); |
| 381 | 1571 |
} |
| 1572 |
+ |
|
| 1573 |
+ #[test] |
|
| 1574 |
+ fn zip_entry_paths_reject_traversal_and_platform_separators() {
|
|
| 1575 |
+ assert_eq!( |
|
| 1576 |
+ safe_zip_entry_path("Payload/App.app/Info.plist").unwrap(),
|
|
| 1577 |
+ PathBuf::from("Payload/App.app/Info.plist")
|
|
| 1578 |
+ ); |
|
| 1579 |
+ for path in [ |
|
| 1580 |
+ "../outside", |
|
| 1581 |
+ "Payload/../../outside", |
|
| 1582 |
+ "/absolute/path", |
|
| 1583 |
+ "Payload\\..\\outside", |
|
| 1584 |
+ ] {
|
|
| 1585 |
+ assert!(safe_zip_entry_path(path).is_err(), "accepted {path}");
|
|
| 1586 |
+ } |
|
| 1587 |
+ } |
|
| 1588 |
+ |
|
| 1589 |
+ #[test] |
|
| 1590 |
+ fn plist_patching_updates_metadata_and_renames_executable() {
|
|
| 1591 |
+ let temp = tempfile::tempdir().unwrap(); |
|
| 1592 |
+ let app_path = temp.path().join("Payload").join("Test.app");
|
|
| 1593 |
+ write_test_app(&app_path, "OriginalExecutable"); |
|
| 1594 |
+ |
|
| 1595 |
+ patch_app_bundle(&app_path, &signing_metadata("PatchedExecutable"), None).unwrap();
|
|
| 1596 |
+ |
|
| 1597 |
+ assert!(!app_path.join("OriginalExecutable").exists());
|
|
| 1598 |
+ assert!(app_path.join("PatchedExecutable").exists());
|
|
| 1599 |
+ let info = Value::from_file(app_path.join("Info.plist"))
|
|
| 1600 |
+ .unwrap() |
|
| 1601 |
+ .into_dictionary() |
|
| 1602 |
+ .unwrap(); |
|
| 1603 |
+ assert_eq!( |
|
| 1604 |
+ info.get("CFBundleIdentifier").and_then(Value::as_string),
|
|
| 1605 |
+ Some("com.example.patched")
|
|
| 1606 |
+ ); |
|
| 1607 |
+ assert_eq!( |
|
| 1608 |
+ info.get("CFBundleDisplayName").and_then(Value::as_string),
|
|
| 1609 |
+ Some("Patched App")
|
|
| 1610 |
+ ); |
|
| 1611 |
+ assert_eq!( |
|
| 1612 |
+ info.get("CFBundleExecutable").and_then(Value::as_string),
|
|
| 1613 |
+ Some("PatchedExecutable")
|
|
| 1614 |
+ ); |
|
| 1615 |
+ assert_eq!( |
|
| 1616 |
+ info.get("UIDeviceFamily")
|
|
| 1617 |
+ .and_then(Value::as_array) |
|
| 1618 |
+ .map(Vec::len), |
|
| 1619 |
+ Some(2) |
|
| 1620 |
+ ); |
|
| 1621 |
+ } |
|
| 1622 |
+ |
|
| 1623 |
+ #[test] |
|
| 1624 |
+ fn nested_bundle_identifiers_are_patched_from_provisioning_map() {
|
|
| 1625 |
+ let temp = tempfile::tempdir().unwrap(); |
|
| 1626 |
+ let app_path = temp.path().join("Payload").join("Test.app");
|
|
| 1627 |
+ let extension_path = app_path.join("PlugIns").join("Widget.appex");
|
|
| 1628 |
+ let framework_path = app_path.join("Frameworks").join("Example.framework");
|
|
| 1629 |
+ for (path, bundle_id) in [ |
|
| 1630 |
+ (&extension_path, "com.example.app.widget"), |
|
| 1631 |
+ (&framework_path, "com.example.framework"), |
|
| 1632 |
+ ] {
|
|
| 1633 |
+ fs::create_dir_all(path).unwrap(); |
|
| 1634 |
+ let mut info = Dictionary::new(); |
|
| 1635 |
+ info.insert( |
|
| 1636 |
+ "CFBundleIdentifier".to_string(), |
|
| 1637 |
+ Value::String(bundle_id.to_string()), |
|
| 1638 |
+ ); |
|
| 1639 |
+ Value::Dictionary(info) |
|
| 1640 |
+ .to_file_binary(path.join("Info.plist"))
|
|
| 1641 |
+ .unwrap(); |
|
| 1642 |
+ } |
|
| 1643 |
+ |
|
| 1644 |
+ patch_nested_bundle_identifiers( |
|
| 1645 |
+ &app_path, |
|
| 1646 |
+ &BTreeMap::from([( |
|
| 1647 |
+ "com.example.app.widget".to_string(), |
|
| 1648 |
+ "com.example.app.TEAM.widget".to_string(), |
|
| 1649 |
+ )]), |
|
| 1650 |
+ ) |
|
| 1651 |
+ .unwrap(); |
|
| 1652 |
+ |
|
| 1653 |
+ let extension_info = Value::from_file(extension_path.join("Info.plist"))
|
|
| 1654 |
+ .unwrap() |
|
| 1655 |
+ .into_dictionary() |
|
| 1656 |
+ .unwrap(); |
|
| 1657 |
+ let framework_info = Value::from_file(framework_path.join("Info.plist"))
|
|
| 1658 |
+ .unwrap() |
|
| 1659 |
+ .into_dictionary() |
|
| 1660 |
+ .unwrap(); |
|
| 1661 |
+ assert_eq!( |
|
| 1662 |
+ plist_string(&extension_info, "CFBundleIdentifier"), |
|
| 1663 |
+ Some("com.example.app.TEAM.widget")
|
|
| 1664 |
+ ); |
|
| 1665 |
+ assert_eq!( |
|
| 1666 |
+ plist_string(&framework_info, "CFBundleIdentifier"), |
|
| 1667 |
+ Some("com.example.framework")
|
|
| 1668 |
+ ); |
|
| 1669 |
+ } |
|
| 1670 |
+ |
|
| 1671 |
+ #[test] |
|
| 1672 |
+ fn icon_override_generates_variants_and_updates_info_plist() {
|
|
| 1673 |
+ let temp = tempfile::tempdir().unwrap(); |
|
| 1674 |
+ let app_path = temp.path().join("Payload").join("Test.app");
|
|
| 1675 |
+ write_test_app(&app_path, "TestExecutable"); |
|
| 1676 |
+ let icon_path = temp.path().join("override.png");
|
|
| 1677 |
+ image::RgbaImage::from_pixel(180, 180, image::Rgba([20, 40, 80, 255])) |
|
| 1678 |
+ .save(&icon_path) |
|
| 1679 |
+ .unwrap(); |
|
| 1680 |
+ |
|
| 1681 |
+ patch_app_bundle( |
|
| 1682 |
+ &app_path, |
|
| 1683 |
+ &signing_metadata("TestExecutable"),
|
|
| 1684 |
+ Some(&icon_path), |
|
| 1685 |
+ ) |
|
| 1686 |
+ .unwrap(); |
|
| 1687 |
+ |
|
| 1688 |
+ assert!(app_path.join("SuperSideloaderIcon60@3x.png").exists());
|
|
| 1689 |
+ assert!(app_path.join("SuperSideloaderIcon76@2x.png").exists());
|
|
| 1690 |
+ assert!(app_path.join("SuperSideloaderIcon83_5@2x.png").exists());
|
|
| 1691 |
+ let info = Value::from_file(app_path.join("Info.plist"))
|
|
| 1692 |
+ .unwrap() |
|
| 1693 |
+ .into_dictionary() |
|
| 1694 |
+ .unwrap(); |
|
| 1695 |
+ assert_eq!( |
|
| 1696 |
+ info.get("CFBundleIconFiles")
|
|
| 1697 |
+ .and_then(Value::as_array) |
|
| 1698 |
+ .and_then(|files| files.first()) |
|
| 1699 |
+ .and_then(Value::as_string), |
|
| 1700 |
+ Some("SuperSideloaderIcon20")
|
|
| 1701 |
+ ); |
|
| 1702 |
+ assert_eq!( |
|
| 1703 |
+ info.get("CFBundleIconFiles")
|
|
| 1704 |
+ .and_then(Value::as_array) |
|
| 1705 |
+ .map(Vec::len), |
|
| 1706 |
+ Some(IPHONE_ICON_NAMES.len()) |
|
| 1707 |
+ ); |
|
| 1708 |
+ assert_eq!( |
|
| 1709 |
+ info.get("CFBundleIconFiles~ipad")
|
|
| 1710 |
+ .and_then(Value::as_array) |
|
| 1711 |
+ .map(Vec::len), |
|
| 1712 |
+ Some(IPAD_ICON_NAMES.len()) |
|
| 1713 |
+ ); |
|
| 1714 |
+ assert!(info.contains_key("CFBundleIcons~ipad"));
|
|
| 1715 |
+ } |
|
| 1716 |
+ |
|
| 1717 |
+ #[tokio::test] |
|
| 1718 |
+ async fn ipa_packaging_round_trips_files_permissions_and_compression() {
|
|
| 1719 |
+ let temp = tempfile::tempdir().unwrap(); |
|
| 1720 |
+ let staging = temp.path().join("staging");
|
|
| 1721 |
+ let executable = staging.join("Payload/Test.app/TestExecutable");
|
|
| 1722 |
+ fs::create_dir_all(executable.parent().unwrap()).unwrap(); |
|
| 1723 |
+ fs::write(&executable, b"executable").unwrap(); |
|
| 1724 |
+ let text = executable.parent().unwrap().join("Config.txt");
|
|
| 1725 |
+ let text_contents = vec![b'A'; 64 * 1024]; |
|
| 1726 |
+ fs::write(&text, &text_contents).unwrap(); |
|
| 1727 |
+ let png = executable.parent().unwrap().join("Icon.png");
|
|
| 1728 |
+ let png_contents = b"already-compressed-image-data"; |
|
| 1729 |
+ fs::write(&png, png_contents).unwrap(); |
|
| 1730 |
+ #[cfg(unix)] |
|
| 1731 |
+ fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); |
|
| 1732 |
+ |
|
| 1733 |
+ let ipa_path = temp.path().join("signed.ipa");
|
|
| 1734 |
+ let mut progress_events = Vec::new(); |
|
| 1735 |
+ package_ipa(&staging, &ipa_path, &mut |event| {
|
|
| 1736 |
+ progress_events.push(event) |
|
| 1737 |
+ }) |
|
| 1738 |
+ .await |
|
| 1739 |
+ .unwrap(); |
|
| 1740 |
+ |
|
| 1741 |
+ assert!(matches!( |
|
| 1742 |
+ progress_events.first(), |
|
| 1743 |
+ Some(IpaSigningProgress::Packaging { completed: 0, .. })
|
|
| 1744 |
+ )); |
|
| 1745 |
+ assert!(matches!( |
|
| 1746 |
+ progress_events.last(), |
|
| 1747 |
+ Some(IpaSigningProgress::Packaging { completed, total }) if completed == total
|
|
| 1748 |
+ )); |
|
| 1749 |
+ |
|
| 1750 |
+ let reader = ZipFileReader::new(&ipa_path).await.unwrap(); |
|
| 1751 |
+ let find_entry = |name: &str| {
|
|
| 1752 |
+ reader |
|
| 1753 |
+ .file() |
|
| 1754 |
+ .entries() |
|
| 1755 |
+ .iter() |
|
| 1756 |
+ .find(|entry| entry_name(entry) == Some(name)) |
|
| 1757 |
+ .unwrap() |
|
| 1758 |
+ }; |
|
| 1759 |
+ let entry = find_entry("Payload/Test.app/TestExecutable");
|
|
| 1760 |
+ #[cfg(unix)] |
|
| 1761 |
+ assert_ne!(entry.unix_permissions().unwrap_or_default() & 0o111, 0); |
|
| 1762 |
+ assert_eq!( |
|
| 1763 |
+ find_entry("Payload/Test.app/Config.txt").compression(),
|
|
| 1764 |
+ Compression::Deflate |
|
| 1765 |
+ ); |
|
| 1766 |
+ assert_eq!( |
|
| 1767 |
+ find_entry("Payload/Test.app/Icon.png").compression(),
|
|
| 1768 |
+ Compression::Stored |
|
| 1769 |
+ ); |
|
| 1770 |
+ |
|
| 1771 |
+ let extracted = temp.path().join("round-trip");
|
|
| 1772 |
+ fs::create_dir(&extracted).unwrap(); |
|
| 1773 |
+ let mut extraction_progress = Vec::new(); |
|
| 1774 |
+ extract_ipa(&ipa_path, &extracted, &mut |event| {
|
|
| 1775 |
+ extraction_progress.push(event) |
|
| 1776 |
+ }) |
|
| 1777 |
+ .await |
|
| 1778 |
+ .unwrap(); |
|
| 1779 |
+ assert_eq!( |
|
| 1780 |
+ fs::read(extracted.join("Payload/Test.app/TestExecutable")).unwrap(),
|
|
| 1781 |
+ b"executable" |
|
| 1782 |
+ ); |
|
| 1783 |
+ assert_eq!( |
|
| 1784 |
+ fs::read(extracted.join("Payload/Test.app/Config.txt")).unwrap(),
|
|
| 1785 |
+ text_contents |
|
| 1786 |
+ ); |
|
| 1787 |
+ assert_eq!( |
|
| 1788 |
+ fs::read(extracted.join("Payload/Test.app/Icon.png")).unwrap(),
|
|
| 1789 |
+ png_contents |
|
| 1790 |
+ ); |
|
| 1791 |
+ assert!(matches!( |
|
| 1792 |
+ extraction_progress.first(), |
|
| 1793 |
+ Some(IpaSigningProgress::Extracting { completed: 0, .. })
|
|
| 1794 |
+ )); |
|
| 1795 |
+ assert!(matches!( |
|
| 1796 |
+ extraction_progress.last(), |
|
| 1797 |
+ Some(IpaSigningProgress::Extracting { completed, total }) if completed == total
|
|
| 1798 |
+ )); |
|
| 1799 |
+ #[cfg(unix)] |
|
| 1800 |
+ assert_ne!( |
|
| 1801 |
+ fs::metadata(extracted.join("Payload/Test.app/TestExecutable"))
|
|
| 1802 |
+ .unwrap() |
|
| 1803 |
+ .permissions() |
|
| 1804 |
+ .mode() |
|
| 1805 |
+ & 0o111, |
|
| 1806 |
+ 0 |
|
| 1807 |
+ ); |
|
| 1808 |
+ } |
|
| 382 | 1809 |
} |
| ... | ... |
@@ -44,5 +44,13 @@ pub(crate) struct DeveloperCertificate {
|
| 44 | 44 |
pub(crate) serial_number: String, |
| 45 | 45 |
pub(crate) machine_name: String, |
| 46 | 46 |
pub(crate) private_key_available: bool, |
| 47 |
+ pub(crate) certificate_fingerprint: Option<String>, |
|
| 47 | 48 |
pub(crate) public_key_fingerprint: Option<String>, |
| 48 | 49 |
} |
| 50 |
+ |
|
| 51 |
+#[derive(Clone, Debug, Eq, PartialEq)] |
|
| 52 |
+pub(crate) struct DeveloperDevice {
|
|
| 53 |
+ pub(crate) id: String, |
|
| 54 |
+ pub(crate) name: String, |
|
| 55 |
+ pub(crate) udid: String, |
|
| 56 |
+} |
| ... | ... |
@@ -12,3 +12,18 @@ pub(crate) enum DeviceWatchEvent {
|
| 12 | 12 |
Changed, |
| 13 | 13 |
Failed(String), |
| 14 | 14 |
} |
| 15 |
+ |
|
| 16 |
+#[derive(Clone, Debug, Eq, PartialEq)] |
|
| 17 |
+pub(crate) enum DeviceInstallProgress {
|
|
| 18 |
+ Connecting, |
|
| 19 |
+ Uploading {
|
|
| 20 |
+ transferred_bytes: u64, |
|
| 21 |
+ total_bytes: u64, |
|
| 22 |
+ completed_files: usize, |
|
| 23 |
+ total_files: usize, |
|
| 24 |
+ }, |
|
| 25 |
+ Installing {
|
|
| 26 |
+ percent: u64, |
|
| 27 |
+ }, |
|
| 28 |
+ Finalizing, |
|
| 29 |
+} |
| ... | ... |
@@ -37,6 +37,12 @@ pub(crate) struct AppMetadata {
|
| 37 | 37 |
pub(crate) supported_devices: Vec<SupportedDeviceFamily>, |
| 38 | 38 |
} |
| 39 | 39 |
|
| 40 |
+#[derive(Clone, Debug, Eq, PartialEq)] |
|
| 41 |
+pub(crate) struct NestedBundle {
|
|
| 42 |
+ pub(crate) name: String, |
|
| 43 |
+ pub(crate) bundle_id: String, |
|
| 44 |
+} |
|
| 45 |
+ |
|
| 40 | 46 |
#[derive(Clone, Debug, PartialEq)] |
| 41 | 47 |
pub(crate) struct AppEntitlement {
|
| 42 | 48 |
pub(crate) key: String, |
| ... | ... |
@@ -66,6 +72,7 @@ pub(crate) enum EntitlementsSource {
|
| 66 | 72 |
#[derive(Clone, Debug, PartialEq)] |
| 67 | 73 |
pub(crate) struct IpaApp {
|
| 68 | 74 |
pub(crate) metadata: AppMetadata, |
| 75 |
+ pub(crate) nested_bundles: Vec<NestedBundle>, |
|
| 69 | 76 |
pub(crate) path: String, |
| 70 | 77 |
pub(crate) icon_path: Option<String>, |
| 71 | 78 |
pub(crate) entitlements: Vec<AppEntitlement>, |
| ... | ... |
@@ -6,7 +6,8 @@ pub(crate) mod ipa; |
| 6 | 6 |
|
| 7 | 7 |
pub(crate) use adi::*; |
| 8 | 8 |
pub(crate) use developer::{
|
| 9 |
- DeveloperAccount, DeveloperAppId, DeveloperAppIdCapability, DeveloperCertificate, DeveloperTeam, |
|
| 9 |
+ DeveloperAccount, DeveloperAppId, DeveloperAppIdCapability, DeveloperCertificate, |
|
| 10 |
+ DeveloperDevice, DeveloperTeam, |
|
| 10 | 11 |
}; |
| 11 | 12 |
pub(crate) use device::*; |
| 12 | 13 |
pub(crate) use identity::*; |
| ... | ... |
@@ -18,11 +18,34 @@ use crate::ui::widgets::{
|
| 18 | 18 |
use futures::{channel::mpsc, StreamExt};
|
| 19 | 19 |
use gpui::{
|
| 20 | 20 |
div, prelude::*, px, App, ClickEvent, Context, ExternalPaths, FocusHandle, FontWeight, |
| 21 |
- InteractiveElement, IntoElement, ParentElement, PathPromptOptions, Render, Styled, Window, |
|
| 21 |
+ InteractiveElement, IntoElement, ParentElement, PathPromptOptions, PromptButton, PromptLevel, |
|
| 22 |
+ Render, Styled, Window, |
|
| 22 | 23 |
}; |
| 23 | 24 |
use gpui_component::button::Button; |
| 25 |
+use gpui_component::text::TextView; |
|
| 24 | 26 |
use std::ops::{Deref, DerefMut};
|
| 25 |
-use std::path::PathBuf; |
|
| 27 |
+use std::path::{Path, PathBuf};
|
|
| 28 |
+ |
|
| 29 |
+const SIDELOAD_SIGNING_WEIGHT: f32 = 0.72; |
|
| 30 |
+ |
|
| 31 |
+#[derive(Clone, Copy, Debug, Eq, PartialEq)] |
|
| 32 |
+enum SigningAction {
|
|
| 33 |
+ Save, |
|
| 34 |
+ Sideload, |
|
| 35 |
+} |
|
| 36 |
+ |
|
| 37 |
+impl SigningAction {
|
|
| 38 |
+ fn installs_after_signing(self) -> bool {
|
|
| 39 |
+ self == Self::Sideload |
|
| 40 |
+ } |
|
| 41 |
+ |
|
| 42 |
+ fn confirmation_label(self) -> &'static str {
|
|
| 43 |
+ match self {
|
|
| 44 |
+ Self::Save => "Create and Sign", |
|
| 45 |
+ Self::Sideload => "Create and Sideload", |
|
| 46 |
+ } |
|
| 47 |
+ } |
|
| 48 |
+} |
|
| 26 | 49 |
|
| 27 | 50 |
pub(crate) struct SideloaderView {
|
| 28 | 51 |
pub(crate) focus_handle: FocusHandle, |
| ... | ... |
@@ -300,6 +323,7 @@ impl SideloaderView {
|
| 300 | 323 |
if self.device_selection.select(index) {
|
| 301 | 324 |
self.open_picker = None; |
| 302 | 325 |
self.save_preferences(); |
| 326 |
+ self.sync_developer_settings_window(cx); |
|
| 303 | 327 |
cx.notify(); |
| 304 | 328 |
} |
| 305 | 329 |
} |
| ... | ... |
@@ -448,33 +472,508 @@ impl SideloaderView {
|
| 448 | 472 |
.detach(); |
| 449 | 473 |
} |
| 450 | 474 |
|
| 451 |
- fn sideload(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
|
|
| 475 |
+ fn sideload(&mut self, event: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
|
|
| 476 |
+ self.start_signing(SigningAction::Sideload, event, window, cx); |
|
| 477 |
+ } |
|
| 478 |
+ |
|
| 479 |
+ fn sign(&mut self, event: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
|
|
| 480 |
+ self.start_signing(SigningAction::Save, event, window, cx); |
|
| 481 |
+ } |
|
| 482 |
+ |
|
| 483 |
+ fn start_signing( |
|
| 484 |
+ &mut self, |
|
| 485 |
+ action: SigningAction, |
|
| 486 |
+ event: &ClickEvent, |
|
| 487 |
+ window: &mut Window, |
|
| 488 |
+ cx: &mut Context<Self>, |
|
| 489 |
+ ) {
|
|
| 452 | 490 |
window.focus(&self.focus_handle, cx); |
| 453 | 491 |
if self.is_busy() |
| 454 | 492 |
|| self.selected_account().is_none() |
| 455 | 493 |
|| self.selected_app().is_none() |
| 456 |
- || self.selected_device().is_none() |
|
| 494 |
+ || (action.installs_after_signing() && self.selected_device().is_none()) |
|
| 457 | 495 |
{
|
| 458 | 496 |
return; |
| 459 | 497 |
} |
| 460 |
- self.start_signing_operation(cx); |
|
| 498 |
+ |
|
| 499 |
+ let Some(account) = self.selected_account().cloned() else {
|
|
| 500 |
+ return; |
|
| 501 |
+ }; |
|
| 502 |
+ let Some(team) = account.teams.get(self.selected_team).cloned() else {
|
|
| 503 |
+ self.fail_signing_and_open_developer_settings( |
|
| 504 |
+ "Select a developer team before signing.", |
|
| 505 |
+ event, |
|
| 506 |
+ window, |
|
| 507 |
+ cx, |
|
| 508 |
+ ); |
|
| 509 |
+ return; |
|
| 510 |
+ }; |
|
| 511 |
+ let Some(certificate) = team.certificates.get(self.selected_certificate).cloned() else {
|
|
| 512 |
+ self.fail_signing_and_open_developer_settings( |
|
| 513 |
+ "Create a development certificate in Developer Settings before signing.", |
|
| 514 |
+ event, |
|
| 515 |
+ window, |
|
| 516 |
+ cx, |
|
| 517 |
+ ); |
|
| 518 |
+ return; |
|
| 519 |
+ }; |
|
| 520 |
+ if !certificate.private_key_available {
|
|
| 521 |
+ self.fail_signing_and_open_developer_settings( |
|
| 522 |
+ "Import the selected certificate's PEM private key in Developer Settings before signing.", |
|
| 523 |
+ event, |
|
| 524 |
+ window, |
|
| 525 |
+ cx, |
|
| 526 |
+ ); |
|
| 527 |
+ return; |
|
| 461 | 528 |
} |
| 529 |
+ let Some(certificate_fingerprint) = certificate.certificate_fingerprint.clone() else {
|
|
| 530 |
+ self.fail_signing_and_open_developer_settings( |
|
| 531 |
+ "Refresh Developer Settings to cache the selected certificate before signing.", |
|
| 532 |
+ event, |
|
| 533 |
+ window, |
|
| 534 |
+ cx, |
|
| 535 |
+ ); |
|
| 536 |
+ return; |
|
| 537 |
+ }; |
|
| 538 |
+ let Some(public_key_fingerprint) = certificate.public_key_fingerprint.clone() else {
|
|
| 539 |
+ self.fail_signing_and_open_developer_settings( |
|
| 540 |
+ "Refresh Developer Settings to inspect the selected certificate before signing.", |
|
| 541 |
+ event, |
|
| 542 |
+ window, |
|
| 543 |
+ cx, |
|
| 544 |
+ ); |
|
| 545 |
+ return; |
|
| 546 |
+ }; |
|
| 547 |
+ let selected_app_id = team.app_ids.get(self.selected_app_id).cloned(); |
|
| 548 |
+ if !self.auto_app_id && selected_app_id.is_none() {
|
|
| 549 |
+ self.fail_signing_and_open_developer_settings( |
|
| 550 |
+ "Select an App ID in Developer Settings before signing.", |
|
| 551 |
+ event, |
|
| 552 |
+ window, |
|
| 553 |
+ cx, |
|
| 554 |
+ ); |
|
| 555 |
+ return; |
|
| 556 |
+ } |
|
| 557 |
+ let developer_context = match self.selected_developer_context() {
|
|
| 558 |
+ Ok(context) => context, |
|
| 559 |
+ Err(error) => {
|
|
| 560 |
+ self.sideload_operation = SideloadOperation::Failed { message: error };
|
|
| 561 |
+ cx.notify(); |
|
| 562 |
+ return; |
|
| 563 |
+ } |
|
| 564 |
+ }; |
|
| 565 |
+ let app = self |
|
| 566 |
+ .selected_app() |
|
| 567 |
+ .cloned() |
|
| 568 |
+ .expect("selected app checked above");
|
|
| 569 |
+ let selected_device = action |
|
| 570 |
+ .installs_after_signing() |
|
| 571 |
+ .then(|| self.selected_device().cloned()) |
|
| 572 |
+ .flatten(); |
|
| 573 |
+ let app_name = app.name().to_string(); |
|
| 574 |
+ let (directory, suggested_name) = signed_ipa_destination(&app.path); |
|
| 575 |
+ let destination_receiver = if action == SigningAction::Save {
|
|
| 576 |
+ Some(cx.prompt_for_new_path(&directory, Some(&suggested_name))) |
|
| 577 |
+ } else {
|
|
| 578 |
+ None |
|
| 579 |
+ }; |
|
| 580 |
+ let auto_app_id = self.auto_app_id; |
|
| 581 |
+ let team_id = team.identifier; |
|
| 582 |
+ let selected_app_id_identifier = selected_app_id |
|
| 583 |
+ .as_ref() |
|
| 584 |
+ .map(|app_id| app_id.identifier.clone()); |
|
| 585 |
+ self.open_picker = None; |
|
| 586 |
+ let (progress_sender, mut progress_receiver) = mpsc::unbounded(); |
|
| 587 |
+ cx.spawn(async move |view, cx| {
|
|
| 588 |
+ while let Some(event) = progress_receiver.next().await {
|
|
| 589 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 590 |
+ view.set_signing_progress(event, action.installs_after_signing()); |
|
| 591 |
+ cx.notify(); |
|
| 592 |
+ }); |
|
| 593 |
+ } |
|
| 594 |
+ }) |
|
| 595 |
+ .detach(); |
|
| 596 |
+ let (install_progress_sender, mut install_progress_receiver) = mpsc::unbounded(); |
|
| 597 |
+ cx.spawn(async move |view, cx| {
|
|
| 598 |
+ while let Some(event) = install_progress_receiver.next().await {
|
|
| 599 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 600 |
+ view.set_installing_progress(event); |
|
| 601 |
+ cx.notify(); |
|
| 602 |
+ }); |
|
| 603 |
+ } |
|
| 604 |
+ }) |
|
| 605 |
+ .detach(); |
|
| 606 |
+ cx.notify(); |
|
| 462 | 607 |
|
| 463 |
- fn sign(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
|
|
| 464 |
- window.focus(&self.focus_handle, cx); |
|
| 465 |
- if self.is_busy() || self.selected_account().is_none() || self.selected_app().is_none() {
|
|
| 608 |
+ cx.spawn_in(window, async move |view, cx| {
|
|
| 609 |
+ let signing_output = if let Some(receiver) = destination_receiver {
|
|
| 610 |
+ match receiver.await {
|
|
| 611 |
+ Ok(Ok(Some(path))) => app_effects::SignIpaOutput::Ipa(path), |
|
| 612 |
+ Ok(Ok(None)) | Err(_) => return, |
|
| 613 |
+ Ok(Err(error)) => {
|
|
| 614 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 615 |
+ view.sideload_operation = SideloadOperation::Failed {
|
|
| 616 |
+ message: format!( |
|
| 617 |
+ "Failed to choose a signed IPA destination: {error}"
|
|
| 618 |
+ ), |
|
| 619 |
+ }; |
|
| 620 |
+ cx.notify(); |
|
| 621 |
+ }); |
|
| 466 | 622 |
return; |
| 467 | 623 |
} |
| 468 |
- self.start_signing_operation(cx); |
|
| 469 | 624 |
} |
| 625 |
+ } else {
|
|
| 626 |
+ app_effects::SignIpaOutput::AppBundle |
|
| 627 |
+ }; |
|
| 470 | 628 |
|
| 471 |
- fn start_signing_operation(&mut self, cx: &mut Context<Self>) {
|
|
| 472 |
- self.open_picker = None; |
|
| 473 |
- self.sideload_operation = SideloadOperation::Running {
|
|
| 629 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 630 |
+ view.sideload_operation = SideloadOperation::Running {
|
|
| 631 |
+ phase: SideloadPhase::Signing, |
|
| 632 |
+ progress: 0.02, |
|
| 633 |
+ detail: "Refreshing App IDs".to_string(), |
|
| 634 |
+ }; |
|
| 635 |
+ cx.notify(); |
|
| 636 |
+ }); |
|
| 637 |
+ |
|
| 638 |
+ let refreshed_account = match cx |
|
| 639 |
+ .background_spawn(app_effects::refresh_account(developer_context.clone())) |
|
| 640 |
+ .await |
|
| 641 |
+ {
|
|
| 642 |
+ Ok(account) => account, |
|
| 643 |
+ Err(error) => {
|
|
| 644 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 645 |
+ view.sideload_operation = SideloadOperation::Failed {
|
|
| 646 |
+ message: format!( |
|
| 647 |
+ "Could not refresh App IDs before signing: {}",
|
|
| 648 |
+ error.user_message() |
|
| 649 |
+ ), |
|
| 650 |
+ }; |
|
| 651 |
+ cx.notify(); |
|
| 652 |
+ }); |
|
| 653 |
+ return; |
|
| 654 |
+ } |
|
| 655 |
+ }; |
|
| 656 |
+ let Some(refreshed_team) = refreshed_account |
|
| 657 |
+ .teams |
|
| 658 |
+ .iter() |
|
| 659 |
+ .find(|team| team.identifier == team_id) |
|
| 660 |
+ .cloned() |
|
| 661 |
+ else {
|
|
| 662 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 663 |
+ view.sideload_operation = SideloadOperation::Failed {
|
|
| 664 |
+ message: "The selected developer team was not returned by Apple." |
|
| 665 |
+ .to_string(), |
|
| 666 |
+ }; |
|
| 667 |
+ cx.notify(); |
|
| 668 |
+ }); |
|
| 669 |
+ return; |
|
| 670 |
+ }; |
|
| 671 |
+ if let Some(device) = selected_device.as_ref() {
|
|
| 672 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 673 |
+ view.sideload_operation = SideloadOperation::Running {
|
|
| 674 |
+ phase: SideloadPhase::Signing, |
|
| 675 |
+ progress: 0.03, |
|
| 676 |
+ detail: "Checking device registration".to_string(), |
|
| 677 |
+ }; |
|
| 678 |
+ cx.notify(); |
|
| 679 |
+ }); |
|
| 680 |
+ let registered_devices = match cx |
|
| 681 |
+ .background_spawn(app_effects::list_developer_devices( |
|
| 682 |
+ developer_context.clone(), |
|
| 683 |
+ team_id.clone(), |
|
| 684 |
+ )) |
|
| 685 |
+ .await |
|
| 686 |
+ {
|
|
| 687 |
+ Ok(devices) => devices, |
|
| 688 |
+ Err(error) => {
|
|
| 689 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 690 |
+ view.sideload_operation = SideloadOperation::Failed {
|
|
| 691 |
+ message: format!( |
|
| 692 |
+ "Could not check whether {} is registered with Apple: {}",
|
|
| 693 |
+ device.name, |
|
| 694 |
+ error.user_message() |
|
| 695 |
+ ), |
|
| 696 |
+ }; |
|
| 697 |
+ cx.notify(); |
|
| 698 |
+ }); |
|
| 699 |
+ return; |
|
| 700 |
+ } |
|
| 701 |
+ }; |
|
| 702 |
+ let device_is_registered = registered_devices.iter().any(|registered| {
|
|
| 703 |
+ registered |
|
| 704 |
+ .udid |
|
| 705 |
+ .trim() |
|
| 706 |
+ .eq_ignore_ascii_case(device.udid.trim()) |
|
| 707 |
+ }); |
|
| 708 |
+ if !device_is_registered {
|
|
| 709 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 710 |
+ view.sideload_operation = SideloadOperation::Running {
|
|
| 474 | 711 |
phase: SideloadPhase::Signing, |
| 475 |
- progress: 0.34, |
|
| 712 |
+ progress: 0.035, |
|
| 713 |
+ detail: format!("Registering {} with Apple", device.name),
|
|
| 476 | 714 |
}; |
| 477 | 715 |
cx.notify(); |
| 716 |
+ }); |
|
| 717 |
+ if let Err(error) = cx |
|
| 718 |
+ .background_spawn(app_effects::add_developer_device( |
|
| 719 |
+ developer_context.clone(), |
|
| 720 |
+ team_id.clone(), |
|
| 721 |
+ device.name.clone(), |
|
| 722 |
+ device.udid.clone(), |
|
| 723 |
+ )) |
|
| 724 |
+ .await |
|
| 725 |
+ {
|
|
| 726 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 727 |
+ view.sideload_operation = SideloadOperation::Failed {
|
|
| 728 |
+ message: format!( |
|
| 729 |
+ "Could not register {} with Apple: {}",
|
|
| 730 |
+ device.name, |
|
| 731 |
+ error.user_message() |
|
| 732 |
+ ), |
|
| 733 |
+ }; |
|
| 734 |
+ cx.notify(); |
|
| 735 |
+ }); |
|
| 736 |
+ return; |
|
| 737 |
+ } |
|
| 738 |
+ } |
|
| 739 |
+ } |
|
| 740 |
+ let refreshed_selected_app_id = selected_app_id_identifier.as_deref().and_then(|id| {
|
|
| 741 |
+ refreshed_team |
|
| 742 |
+ .app_ids |
|
| 743 |
+ .iter() |
|
| 744 |
+ .find(|app_id| app_id.identifier == id) |
|
| 745 |
+ .cloned() |
|
| 746 |
+ }); |
|
| 747 |
+ let plan = match app_effects::app_id_provisioning_plan( |
|
| 748 |
+ &refreshed_team, |
|
| 749 |
+ auto_app_id, |
|
| 750 |
+ refreshed_selected_app_id.clone(), |
|
| 751 |
+ &app, |
|
| 752 |
+ ) {
|
|
| 753 |
+ Ok(plan) => plan, |
|
| 754 |
+ Err(error) => {
|
|
| 755 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 756 |
+ view.state |
|
| 757 |
+ .replace_developer_account_preserving_selection(refreshed_account); |
|
| 758 |
+ view.save_preferences(); |
|
| 759 |
+ view.sync_settings_windows(cx); |
|
| 760 |
+ view.sideload_operation = SideloadOperation::Failed {
|
|
| 761 |
+ message: error.user_message(), |
|
| 762 |
+ }; |
|
| 763 |
+ cx.notify(); |
|
| 764 |
+ }); |
|
| 765 |
+ return; |
|
| 766 |
+ } |
|
| 767 |
+ }; |
|
| 768 |
+ |
|
| 769 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 770 |
+ view.state |
|
| 771 |
+ .replace_developer_account_preserving_selection(refreshed_account); |
|
| 772 |
+ view.save_preferences(); |
|
| 773 |
+ view.sync_settings_windows(cx); |
|
| 774 |
+ cx.notify(); |
|
| 775 |
+ }); |
|
| 776 |
+ |
|
| 777 |
+ if plan.app_ids.len() > 1 {
|
|
| 778 |
+ let (message, detail) = app_id_provisioning_prompt(&plan); |
|
| 779 |
+ let prompt = match view.update_in(cx, |_, window, cx| {
|
|
| 780 |
+ window.prompt( |
|
| 781 |
+ PromptLevel::Warning, |
|
| 782 |
+ &message, |
|
| 783 |
+ Some(&detail), |
|
| 784 |
+ &[ |
|
| 785 |
+ PromptButton::new(action.confirmation_label()), |
|
| 786 |
+ PromptButton::Cancel("Cancel".into()),
|
|
| 787 |
+ ], |
|
| 788 |
+ cx, |
|
| 789 |
+ ) |
|
| 790 |
+ }) {
|
|
| 791 |
+ Ok(prompt) => prompt, |
|
| 792 |
+ Err(_) => return, |
|
| 793 |
+ }; |
|
| 794 |
+ if prompt.await != Ok(0) {
|
|
| 795 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 796 |
+ view.sideload_operation = SideloadOperation::Idle; |
|
| 797 |
+ cx.notify(); |
|
| 798 |
+ }); |
|
| 799 |
+ return; |
|
| 800 |
+ } |
|
| 801 |
+ } |
|
| 802 |
+ |
|
| 803 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 804 |
+ view.sideload_operation = SideloadOperation::Running {
|
|
| 805 |
+ phase: SideloadPhase::Signing, |
|
| 806 |
+ progress: 0.04, |
|
| 807 |
+ detail: "Preparing signing resources".to_string(), |
|
| 808 |
+ }; |
|
| 809 |
+ cx.notify(); |
|
| 810 |
+ }); |
|
| 811 |
+ let request = app_effects::SignIpaRequest {
|
|
| 812 |
+ developer_context, |
|
| 813 |
+ team_id, |
|
| 814 |
+ team_app_ids: refreshed_team.app_ids, |
|
| 815 |
+ certificate_fingerprint, |
|
| 816 |
+ public_key_fingerprint, |
|
| 817 |
+ auto_app_id, |
|
| 818 |
+ selected_app_id: refreshed_selected_app_id, |
|
| 819 |
+ app, |
|
| 820 |
+ output: signing_output, |
|
| 821 |
+ }; |
|
| 822 |
+ let result = cx |
|
| 823 |
+ .background_spawn(async move {
|
|
| 824 |
+ app_effects::sign_ipa(request, move |event| {
|
|
| 825 |
+ let _ = progress_sender.unbounded_send(event); |
|
| 826 |
+ }) |
|
| 827 |
+ .await |
|
| 828 |
+ .map_err(|error| error.user_message()) |
|
| 829 |
+ }) |
|
| 830 |
+ .await; |
|
| 831 |
+ let outcome = match result {
|
|
| 832 |
+ Ok(outcome) => outcome, |
|
| 833 |
+ Err(message) => {
|
|
| 834 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 835 |
+ view.sideload_operation = SideloadOperation::Failed { message };
|
|
| 836 |
+ cx.notify(); |
|
| 837 |
+ }); |
|
| 838 |
+ return; |
|
| 839 |
+ } |
|
| 840 |
+ }; |
|
| 841 |
+ let app_effects::SignIpaOutcome {
|
|
| 842 |
+ artifact, |
|
| 843 |
+ updated_account, |
|
| 844 |
+ } = outcome; |
|
| 845 |
+ |
|
| 846 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 847 |
+ if let Some(account) = updated_account {
|
|
| 848 |
+ view.state |
|
| 849 |
+ .replace_developer_account_preserving_selection(account); |
|
| 850 |
+ view.save_preferences(); |
|
| 851 |
+ view.sync_settings_windows(cx); |
|
| 852 |
+ } |
|
| 853 |
+ cx.notify(); |
|
| 854 |
+ }); |
|
| 855 |
+ if action == SigningAction::Save {
|
|
| 856 |
+ let app_effects::SignIpaArtifact::Ipa(output_path) = artifact else {
|
|
| 857 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 858 |
+ view.sideload_operation = SideloadOperation::Failed {
|
|
| 859 |
+ message: "Signing completed without producing an IPA.".to_string(), |
|
| 860 |
+ }; |
|
| 861 |
+ cx.notify(); |
|
| 862 |
+ }); |
|
| 863 |
+ return; |
|
| 864 |
+ }; |
|
| 865 |
+ let file_name = output_path |
|
| 866 |
+ .file_name() |
|
| 867 |
+ .and_then(|name| name.to_str()) |
|
| 868 |
+ .unwrap_or("signed IPA");
|
|
| 869 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 870 |
+ view.sideload_operation = SideloadOperation::Finished {
|
|
| 871 |
+ message: format!("Signed {file_name}"),
|
|
| 872 |
+ }; |
|
| 873 |
+ cx.notify(); |
|
| 874 |
+ }); |
|
| 875 |
+ return; |
|
| 876 |
+ } |
|
| 877 |
+ |
|
| 878 |
+ let app_effects::SignIpaArtifact::AppBundle(signed_app) = artifact else {
|
|
| 879 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 880 |
+ view.sideload_operation = SideloadOperation::Failed {
|
|
| 881 |
+ message: "Signing unexpectedly produced an IPA for sideloading." |
|
| 882 |
+ .to_string(), |
|
| 883 |
+ }; |
|
| 884 |
+ cx.notify(); |
|
| 885 |
+ }); |
|
| 886 |
+ return; |
|
| 887 |
+ }; |
|
| 888 |
+ |
|
| 889 |
+ let device = selected_device.expect("sideload device selected above");
|
|
| 890 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 891 |
+ view.sideload_operation = SideloadOperation::Running {
|
|
| 892 |
+ phase: SideloadPhase::Installing, |
|
| 893 |
+ progress: SIDELOAD_SIGNING_WEIGHT, |
|
| 894 |
+ detail: format!("Connecting to {}", device.name),
|
|
| 895 |
+ }; |
|
| 896 |
+ cx.notify(); |
|
| 897 |
+ }); |
|
| 898 |
+ let install_result = cx |
|
| 899 |
+ .background_spawn(app_effects::install_app( |
|
| 900 |
+ device.udid.clone(), |
|
| 901 |
+ signed_app, |
|
| 902 |
+ move |event| {
|
|
| 903 |
+ let _ = install_progress_sender.unbounded_send(event); |
|
| 904 |
+ }, |
|
| 905 |
+ )) |
|
| 906 |
+ .await |
|
| 907 |
+ .map_err(|error| error.user_message()); |
|
| 908 |
+ |
|
| 909 |
+ let _ = view.update(cx, |view, cx| {
|
|
| 910 |
+ view.sideload_operation = match install_result {
|
|
| 911 |
+ Ok(()) => SideloadOperation::Finished {
|
|
| 912 |
+ message: format!("Installed {app_name} on {}", device.name),
|
|
| 913 |
+ }, |
|
| 914 |
+ Err(message) => SideloadOperation::Failed { message },
|
|
| 915 |
+ }; |
|
| 916 |
+ cx.notify(); |
|
| 917 |
+ }); |
|
| 918 |
+ }) |
|
| 919 |
+ .detach(); |
|
| 920 |
+ } |
|
| 921 |
+ |
|
| 922 |
+ fn fail_signing_and_open_developer_settings( |
|
| 923 |
+ &mut self, |
|
| 924 |
+ message: &str, |
|
| 925 |
+ event: &ClickEvent, |
|
| 926 |
+ window: &mut Window, |
|
| 927 |
+ cx: &mut Context<Self>, |
|
| 928 |
+ ) {
|
|
| 929 |
+ self.sideload_operation = SideloadOperation::Failed {
|
|
| 930 |
+ message: message.to_string(), |
|
| 931 |
+ }; |
|
| 932 |
+ self.open_team_settings(event, window, cx); |
|
| 933 |
+ } |
|
| 934 |
+ |
|
| 935 |
+ fn set_signing_progress( |
|
| 936 |
+ &mut self, |
|
| 937 |
+ event: app_effects::SignIpaProgress, |
|
| 938 |
+ installs_after_signing: bool, |
|
| 939 |
+ ) {
|
|
| 940 |
+ let SideloadOperation::Running {
|
|
| 941 |
+ phase: SideloadPhase::Signing, |
|
| 942 |
+ progress: current_progress, |
|
| 943 |
+ .. |
|
| 944 |
+ } = &self.sideload_operation |
|
| 945 |
+ else {
|
|
| 946 |
+ return; |
|
| 947 |
+ }; |
|
| 948 |
+ let next_progress = if installs_after_signing {
|
|
| 949 |
+ event.progress() * SIDELOAD_SIGNING_WEIGHT |
|
| 950 |
+ } else {
|
|
| 951 |
+ event.progress() |
|
| 952 |
+ }; |
|
| 953 |
+ let progress = next_progress.max(*current_progress); |
|
| 954 |
+ self.sideload_operation = SideloadOperation::Running {
|
|
| 955 |
+ phase: SideloadPhase::Signing, |
|
| 956 |
+ progress, |
|
| 957 |
+ detail: event.label(), |
|
| 958 |
+ }; |
|
| 959 |
+ } |
|
| 960 |
+ |
|
| 961 |
+ fn set_installing_progress(&mut self, event: app_effects::InstallAppProgress) {
|
|
| 962 |
+ let SideloadOperation::Running {
|
|
| 963 |
+ phase: SideloadPhase::Installing, |
|
| 964 |
+ progress: current_progress, |
|
| 965 |
+ .. |
|
| 966 |
+ } = &self.sideload_operation |
|
| 967 |
+ else {
|
|
| 968 |
+ return; |
|
| 969 |
+ }; |
|
| 970 |
+ let next_progress = |
|
| 971 |
+ SIDELOAD_SIGNING_WEIGHT + event.progress() * (1. - SIDELOAD_SIGNING_WEIGHT); |
|
| 972 |
+ self.sideload_operation = SideloadOperation::Running {
|
|
| 973 |
+ phase: SideloadPhase::Installing, |
|
| 974 |
+ progress: next_progress.max(*current_progress), |
|
| 975 |
+ detail: event.label(), |
|
| 976 |
+ }; |
|
| 478 | 977 |
} |
| 479 | 978 |
|
| 480 | 979 |
fn refresh_devices_from_button( |
| ... | ... |
@@ -544,6 +1043,7 @@ impl SideloaderView {
|
| 544 | 1043 |
.await; |
| 545 | 1044 |
let _ = view.update(cx, |view, cx| {
|
| 546 | 1045 |
view.finish_device_refresh(generation, result); |
| 1046 |
+ view.sync_developer_settings_window(cx); |
|
| 547 | 1047 |
cx.notify(); |
| 548 | 1048 |
}); |
| 549 | 1049 |
}) |
| ... | ... |
@@ -1200,6 +1700,26 @@ impl SideloaderView {
|
| 1200 | 1700 |
selected_app, |
| 1201 | 1701 |
self.selected_device(), |
| 1202 | 1702 |
); |
| 1703 |
+ let status_label = if matches!(self.sideload_operation, SideloadOperation::Failed { .. }) {
|
|
| 1704 |
+ TextView::markdown("sideload-error-text", escape_markdown_text(&status_text))
|
|
| 1705 |
+ .selectable(true) |
|
| 1706 |
+ .w(px(SIDELOAD_BUTTON_WIDTH)) |
|
| 1707 |
+ .text_center() |
|
| 1708 |
+ .text_xs() |
|
| 1709 |
+ .line_height(px(16.)) |
|
| 1710 |
+ .text_color(rgb(status_color)) |
|
| 1711 |
+ .into_any_element() |
|
| 1712 |
+ } else {
|
|
| 1713 |
+ div() |
|
| 1714 |
+ .w(px(SIDELOAD_BUTTON_WIDTH)) |
|
| 1715 |
+ .text_center() |
|
| 1716 |
+ .text_xs() |
|
| 1717 |
+ .line_height(px(16.)) |
|
| 1718 |
+ .text_color(rgb(status_color)) |
|
| 1719 |
+ .whitespace_normal() |
|
| 1720 |
+ .child(status_text) |
|
| 1721 |
+ .into_any_element() |
|
| 1722 |
+ }; |
|
| 1203 | 1723 |
|
| 1204 | 1724 |
div() |
| 1205 | 1725 |
.w(px(SIDELOAD_CONNECTOR_WIDTH)) |
| ... | ... |
@@ -1245,7 +1765,7 @@ impl SideloaderView {
|
| 1245 | 1765 |
) |
| 1246 | 1766 |
.child( |
| 1247 | 1767 |
div() |
| 1248 |
- .h(px(SIDELOAD_STATUS_HEIGHT)) |
|
| 1768 |
+ .min_h(px(SIDELOAD_STATUS_HEIGHT)) |
|
| 1249 | 1769 |
.flex() |
| 1250 | 1770 |
.items_center() |
| 1251 | 1771 |
.gap_2() |
| ... | ... |
@@ -1270,15 +1790,7 @@ impl SideloaderView {
|
| 1270 | 1790 |
.bg(rgb(status_color)), |
| 1271 | 1791 |
), |
| 1272 | 1792 |
) |
| 1273 |
- .child( |
|
| 1274 |
- div() |
|
| 1275 |
- .w(px(SIDELOAD_BUTTON_WIDTH)) |
|
| 1276 |
- .text_center() |
|
| 1277 |
- .text_xs() |
|
| 1278 |
- .text_color(rgb(status_color)) |
|
| 1279 |
- .text_ellipsis() |
|
| 1280 |
- .child(status_text), |
|
| 1281 |
- ), |
|
| 1793 |
+ .child(status_label), |
|
| 1282 | 1794 |
) |
| 1283 | 1795 |
.child(div().flex_none().w_6()), |
| 1284 | 1796 |
) |
| ... | ... |
@@ -1509,7 +2021,7 @@ fn sideload_top_label(operation: &SideloadOperation) -> &'static str {
|
| 1509 | 2021 |
match operation {
|
| 1510 | 2022 |
SideloadOperation::Idle => "Draft", |
| 1511 | 2023 |
SideloadOperation::Running { phase, .. } => sideload_phase_label(*phase),
|
| 1512 |
- SideloadOperation::Finished => "Done", |
|
| 2024 |
+ SideloadOperation::Finished { .. } => "Done",
|
|
| 1513 | 2025 |
SideloadOperation::Failed { .. } => "Failed",
|
| 1514 | 2026 |
} |
| 1515 | 2027 |
} |
| ... | ... |
@@ -1518,7 +2030,7 @@ fn sideload_status_color(operation: &SideloadOperation) -> u32 {
|
| 1518 | 2030 |
match operation {
|
| 1519 | 2031 |
SideloadOperation::Idle => 0x53666d, |
| 1520 | 2032 |
SideloadOperation::Running { .. } => 0x173f45,
|
| 1521 |
- SideloadOperation::Finished => 0x1d6b45, |
|
| 2033 |
+ SideloadOperation::Finished { .. } => 0x1d6b45,
|
|
| 1522 | 2034 |
SideloadOperation::Failed { .. } => 0x9a302b,
|
| 1523 | 2035 |
} |
| 1524 | 2036 |
} |
| ... | ... |
@@ -1526,7 +2038,7 @@ fn sideload_status_color(operation: &SideloadOperation) -> u32 {
|
| 1526 | 2038 |
fn sideload_button_label(operation: &SideloadOperation) -> &'static str {
|
| 1527 | 2039 |
match operation {
|
| 1528 | 2040 |
SideloadOperation::Idle |
| 1529 |
- | SideloadOperation::Finished |
|
| 2041 |
+ | SideloadOperation::Finished { .. }
|
|
| 1530 | 2042 |
| SideloadOperation::Failed { .. } => "Sideload",
|
| 1531 | 2043 |
SideloadOperation::Running { phase, .. } => sideload_phase_label(*phase),
|
| 1532 | 2044 |
} |
| ... | ... |
@@ -1550,7 +2062,15 @@ fn sideload_status_text( |
| 1550 | 2062 |
"Ready to sign".to_string() |
| 1551 | 2063 |
} |
| 1552 | 2064 |
} |
| 1553 |
- SideloadOperation::Running { phase, .. } => match phase {
|
|
| 2065 |
+ SideloadOperation::Running {
|
|
| 2066 |
+ phase, |
|
| 2067 |
+ progress, |
|
| 2068 |
+ detail, |
|
| 2069 |
+ } => {
|
|
| 2070 |
+ if !detail.is_empty() {
|
|
| 2071 |
+ format!("{detail} - {:.0}%", progress.clamp(0., 1.) * 100.)
|
|
| 2072 |
+ } else {
|
|
| 2073 |
+ match phase {
|
|
| 1554 | 2074 |
SideloadPhase::Signing => {
|
| 1555 | 2075 |
let app_name = app.map(|app| app.name().as_str()).unwrap_or("app");
|
| 1556 | 2076 |
format!("Signing {app_name}")
|
| ... | ... |
@@ -1561,17 +2081,43 @@ fn sideload_status_text( |
| 1561 | 2081 |
.unwrap_or("device");
|
| 1562 | 2082 |
format!("Installing to {device_name}")
|
| 1563 | 2083 |
} |
| 1564 |
- }, |
|
| 1565 |
- SideloadOperation::Finished => {
|
|
| 1566 |
- let device_name = device |
|
| 1567 |
- .map(|device| device.name.as_str()) |
|
| 1568 |
- .unwrap_or("device");
|
|
| 1569 |
- format!("Installed on {device_name}")
|
|
| 1570 | 2084 |
} |
| 2085 |
+ } |
|
| 2086 |
+ } |
|
| 2087 |
+ SideloadOperation::Finished { message } => message.clone(),
|
|
| 1571 | 2088 |
SideloadOperation::Failed { message } => message.clone(),
|
| 1572 | 2089 |
} |
| 1573 | 2090 |
} |
| 1574 | 2091 |
|
| 2092 |
+fn escape_markdown_text(text: &str) -> String {
|
|
| 2093 |
+ text.chars() |
|
| 2094 |
+ .flat_map(|character| {
|
|
| 2095 |
+ if matches!( |
|
| 2096 |
+ character, |
|
| 2097 |
+ '\\' | '`' |
|
| 2098 |
+ | '*' |
|
| 2099 |
+ | '_' |
|
| 2100 |
+ | '{'
|
|
| 2101 |
+ | '}' |
|
| 2102 |
+ | '[' |
|
| 2103 |
+ | ']' |
|
| 2104 |
+ | '<' |
|
| 2105 |
+ | '>' |
|
| 2106 |
+ | '#' |
|
| 2107 |
+ | '+' |
|
| 2108 |
+ | '-' |
|
| 2109 |
+ | '.' |
|
| 2110 |
+ | '!' |
|
| 2111 |
+ | '|' |
|
| 2112 |
+ ) {
|
|
| 2113 |
+ vec!['\\', character] |
|
| 2114 |
+ } else {
|
|
| 2115 |
+ vec![character] |
|
| 2116 |
+ } |
|
| 2117 |
+ }) |
|
| 2118 |
+ .collect() |
|
| 2119 |
+} |
|
| 2120 |
+ |
|
| 1575 | 2121 |
fn sideload_phase_label(phase: SideloadPhase) -> &'static str {
|
| 1576 | 2122 |
match phase {
|
| 1577 | 2123 |
SideloadPhase::Signing => "Signing", |
| ... | ... |
@@ -1587,6 +2133,43 @@ fn first_ipa_path(paths: &ExternalPaths) -> Option<PathBuf> {
|
| 1587 | 2133 |
.cloned() |
| 1588 | 2134 |
} |
| 1589 | 2135 |
|
| 2136 |
+fn signed_ipa_destination(app_path: &str) -> (PathBuf, String) {
|
|
| 2137 |
+ let path = Path::new(app_path); |
|
| 2138 |
+ let directory = path |
|
| 2139 |
+ .parent() |
|
| 2140 |
+ .filter(|parent| !parent.as_os_str().is_empty()) |
|
| 2141 |
+ .unwrap_or_else(|| Path::new("."))
|
|
| 2142 |
+ .to_path_buf(); |
|
| 2143 |
+ let stem = path |
|
| 2144 |
+ .file_stem() |
|
| 2145 |
+ .and_then(|value| value.to_str()) |
|
| 2146 |
+ .filter(|value| !value.is_empty()) |
|
| 2147 |
+ .unwrap_or("app");
|
|
| 2148 |
+ (directory, format!("{stem}-signed.ipa"))
|
|
| 2149 |
+} |
|
| 2150 |
+ |
|
| 2151 |
+fn app_id_provisioning_prompt(plan: &app_effects::AppIdProvisioningPlan) -> (String, String) {
|
|
| 2152 |
+ let count = plan.app_ids.len(); |
|
| 2153 |
+ let message = format!("Create {count} App IDs before signing?");
|
|
| 2154 |
+ let mut detail = format!( |
|
| 2155 |
+ "Super Sideloader needs to add these App IDs:\n\n{}",
|
|
| 2156 |
+ plan.app_ids |
|
| 2157 |
+ .iter() |
|
| 2158 |
+ .map(|app_id| format!("- {} ({})", app_id.name, app_id.identifier))
|
|
| 2159 |
+ .collect::<Vec<_>>() |
|
| 2160 |
+ .join("\n")
|
|
| 2161 |
+ ); |
|
| 2162 |
+ if let Some(available) = plan.available_quantity {
|
|
| 2163 |
+ let remaining = plan.remaining_after_signing().unwrap_or_default(); |
|
| 2164 |
+ detail.push_str(&format!( |
|
| 2165 |
+ "\n\nApple currently reports {available} App IDs remaining. {remaining} will remain after these are created."
|
|
| 2166 |
+ )); |
|
| 2167 |
+ } else {
|
|
| 2168 |
+ detail.push_str("\n\nApple did not report the remaining App ID quota.");
|
|
| 2169 |
+ } |
|
| 2170 |
+ (message, detail) |
|
| 2171 |
+} |
|
| 2172 |
+ |
|
| 1590 | 2173 |
fn paths_include_ipa(paths: &ExternalPaths) -> bool {
|
| 1591 | 2174 |
first_ipa_path(paths).is_some() |
| 1592 | 2175 |
} |
| ... | ... |
@@ -8,8 +8,8 @@ use crate::app::effects::{
|
| 8 | 8 |
}; |
| 9 | 9 |
use crate::app::models::{
|
| 10 | 10 |
AccountOption, AdiBackendKind, AdiBackendOption, AdiProvisioningState, AppEntitlement, |
| 11 |
- AppIdOption, AppMetadataField, AppOption, DevelopmentCertificateOption, EntitlementValue, |
|
| 12 |
- MachineIdentity, SupportedDeviceFamily, TeamOption, |
|
| 11 |
+ AppIdOption, AppMetadataField, AppOption, DeveloperDeviceOption, DevelopmentCertificateOption, |
|
| 12 |
+ DeviceOption, EntitlementValue, MachineIdentity, SupportedDeviceFamily, TeamOption, |
|
| 13 | 13 |
}; |
| 14 | 14 |
use crate::app::preferences::ThemePreference; |
| 15 | 15 |
use crate::app::state::SideloaderState; |
| ... | ... |
@@ -62,6 +62,7 @@ struct TeamSettingsSnapshot {
|
| 62 | 62 |
selected_certificate: usize, |
| 63 | 63 |
auto_app_id: bool, |
| 64 | 64 |
selected_app_id: usize, |
| 65 |
+ selected_device: Option<DeviceOption>, |
|
| 65 | 66 |
} |
| 66 | 67 |
|
| 67 | 68 |
#[derive(Clone)] |
| ... | ... |
@@ -108,6 +109,12 @@ pub(crate) struct SettingsWindow {
|
| 108 | 109 |
app_detail_edit: Option<AppDetailEdit>, |
| 109 | 110 |
app_id_add_form: Option<AppIdAddForm>, |
| 110 | 111 |
app_id_edit_form: Option<AppIdEditForm>, |
| 112 |
+ developer_device_add_form: Option<DeveloperDeviceAddForm>, |
|
| 113 |
+ developer_devices: Vec<DeveloperDeviceOption>, |
|
| 114 |
+ developer_devices_team_id: Option<String>, |
|
| 115 |
+ developer_devices_loading: bool, |
|
| 116 |
+ developer_devices_error: Option<SharedString>, |
|
| 117 |
+ developer_devices_request_generation: u64, |
|
| 111 | 118 |
selected_entitlement: Option<usize>, |
| 112 | 119 |
entitlement_edit: Option<EntitlementEdit>, |
| 113 | 120 |
entitlement_type_picker_open: bool, |
| ... | ... |
@@ -313,6 +320,13 @@ struct AppIdEditForm {
|
| 313 | 320 |
capabilities: Vec<AppIdCapabilityEdit>, |
| 314 | 321 |
} |
| 315 | 322 |
|
| 323 |
+#[derive(Clone)] |
|
| 324 |
+struct DeveloperDeviceAddForm {
|
|
| 325 |
+ team_id: String, |
|
| 326 |
+ name: Entity<InputState>, |
|
| 327 |
+ udid: Entity<InputState>, |
|
| 328 |
+} |
|
| 329 |
+ |
|
| 316 | 330 |
#[derive(Clone)] |
| 317 | 331 |
struct AppIdCapabilityEdit {
|
| 318 | 332 |
key: String, |
| ... | ... |
@@ -460,6 +474,12 @@ fn settings_task_is_current( |
| 460 | 474 |
current_generation == task_generation && current_mode == expected_mode |
| 461 | 475 |
} |
| 462 | 476 |
|
| 477 |
+fn developer_device_form_defaults(selected_device: Option<&DeviceOption>) -> (String, String) {
|
|
| 478 |
+ selected_device |
|
| 479 |
+ .map(|device| (device.name.clone(), device.udid.clone())) |
|
| 480 |
+ .unwrap_or_default() |
|
| 481 |
+} |
|
| 482 |
+ |
|
| 463 | 483 |
impl SettingsSnapshot {
|
| 464 | 484 |
fn from_state(mode: &SettingsMode, state: &SideloaderState) -> Option<Self> {
|
| 465 | 485 |
match mode {
|
| ... | ... |
@@ -483,6 +503,7 @@ impl SettingsSnapshot {
|
| 483 | 503 |
selected_certificate: state.selected_certificate, |
| 484 | 504 |
auto_app_id: state.auto_app_id, |
| 485 | 505 |
selected_app_id: state.selected_app_id, |
| 506 |
+ selected_device: state.selected_device().cloned(), |
|
| 486 | 507 |
})) |
| 487 | 508 |
} |
| 488 | 509 |
SettingsMode::AppSettings { app_index } => {
|
| ... | ... |
@@ -520,7 +541,13 @@ impl SettingsWindow {
|
| 520 | 541 |
) -> Self {
|
| 521 | 542 |
let focus_handle = cx.focus_handle(); |
| 522 | 543 |
window.focus(&focus_handle, cx); |
| 523 |
- cx.defer_in(window, |_, _, cx| cx.notify()); |
|
| 544 |
+ cx.defer_in(window, |settings, window, cx| {
|
|
| 545 |
+ if matches!(settings.mode, SettingsMode::Team) {
|
|
| 546 |
+ settings.load_developer_devices(window, cx); |
|
| 547 |
+ } else {
|
|
| 548 |
+ cx.notify(); |
|
| 549 |
+ } |
|
| 550 |
+ }); |
|
| 524 | 551 |
cx.observe_window_appearance(window, |settings, window, cx| {
|
| 525 | 552 |
sync_window_theme(window, cx, settings.theme_preference); |
| 526 | 553 |
cx.notify(); |
| ... | ... |
@@ -545,6 +572,12 @@ impl SettingsWindow {
|
| 545 | 572 |
app_detail_edit: None, |
| 546 | 573 |
app_id_add_form: None, |
| 547 | 574 |
app_id_edit_form: None, |
| 575 |
+ developer_device_add_form: None, |
|
| 576 |
+ developer_devices: Vec::new(), |
|
| 577 |
+ developer_devices_team_id: None, |
|
| 578 |
+ developer_devices_loading: false, |
|
| 579 |
+ developer_devices_error: None, |
|
| 580 |
+ developer_devices_request_generation: 0, |
|
| 548 | 581 |
selected_entitlement: None, |
| 549 | 582 |
entitlement_edit: None, |
| 550 | 583 |
entitlement_type_picker_open: false, |
| ... | ... |
@@ -858,6 +891,13 @@ impl SettingsWindow {
|
| 858 | 891 |
self.app_detail_edit = None; |
| 859 | 892 |
self.app_id_add_form = None; |
| 860 | 893 |
self.app_id_edit_form = None; |
| 894 |
+ self.developer_device_add_form = None; |
|
| 895 |
+ self.developer_devices.clear(); |
|
| 896 |
+ self.developer_devices_team_id = None; |
|
| 897 |
+ self.developer_devices_loading = false; |
|
| 898 |
+ self.developer_devices_error = None; |
|
| 899 |
+ self.developer_devices_request_generation = |
|
| 900 |
+ self.developer_devices_request_generation.wrapping_add(1); |
|
| 861 | 901 |
self.selected_entitlement = None; |
| 862 | 902 |
self.entitlement_edit = None; |
| 863 | 903 |
self.entitlement_type_picker_open = false; |
| ... | ... |
@@ -871,6 +911,9 @@ impl SettingsWindow {
|
| 871 | 911 |
window.set_window_title(title); |
| 872 | 912 |
window.activate_window(); |
| 873 | 913 |
window.focus(&self.focus_handle, cx); |
| 914 |
+ if matches!(self.mode, SettingsMode::Team) {
|
|
| 915 |
+ self.load_developer_devices(window, cx); |
|
| 916 |
+ } |
|
| 874 | 917 |
cx.defer_in(window, |_, _, cx| cx.notify()); |
| 875 | 918 |
cx.notify(); |
| 876 | 919 |
} |
| ... | ... |
@@ -1058,7 +1101,7 @@ impl SettingsWindow {
|
| 1058 | 1101 |
&mut self, |
| 1059 | 1102 |
index: usize, |
| 1060 | 1103 |
_: &ClickEvent, |
| 1061 |
- _: &mut Window, |
|
| 1104 |
+ window: &mut Window, |
|
| 1062 | 1105 |
cx: &mut Context<Self>, |
| 1063 | 1106 |
) {
|
| 1064 | 1107 |
let Some(snapshot) = self.team_snapshot(cx) else {
|
| ... | ... |
@@ -1074,7 +1117,323 @@ impl SettingsWindow {
|
| 1074 | 1117 |
self.app_id_picker_open = false; |
| 1075 | 1118 |
self.app_id_add_form = None; |
| 1076 | 1119 |
self.app_id_edit_form = None; |
| 1077 |
- let _ = self.dispatch_parent_action(SettingsParentAction::SelectTeam(index), cx); |
|
| 1120 |
+ self.developer_device_add_form = None; |
|
| 1121 |
+ if self |
|
| 1122 |
+ .dispatch_parent_action(SettingsParentAction::SelectTeam(index), cx) |
|
| 1123 |
+ .is_ok() |
|
| 1124 |
+ {
|
|
| 1125 |
+ self.load_developer_devices(window, cx); |
|
| 1126 |
+ } |
|
| 1127 |
+ cx.notify(); |
|
| 1128 |
+ } |
|
| 1129 |
+ |
|
| 1130 |
+ fn current_developer_team_id(&self, cx: &mut Context<Self>) -> Option<String> {
|
|
| 1131 |
+ let snapshot = self.team_snapshot(cx)?; |
|
| 1132 |
+ snapshot |
|
| 1133 |
+ .teams |
|
| 1134 |
+ .get(snapshot.selected_team) |
|
| 1135 |
+ .map(|team| team.identifier.clone()) |
|
| 1136 |
+ } |
|
| 1137 |
+ |
|
| 1138 |
+ fn accepts_developer_device_result( |
|
| 1139 |
+ &self, |
|
| 1140 |
+ task_generation: u64, |
|
| 1141 |
+ task_mode: &SettingsMode, |
|
| 1142 |
+ device_request_generation: u64, |
|
| 1143 |
+ team_id: &str, |
|
| 1144 |
+ cx: &mut Context<Self>, |
|
| 1145 |
+ ) -> bool {
|
|
| 1146 |
+ self.accepts_task_result(task_generation, task_mode) |
|
| 1147 |
+ && self.developer_devices_request_generation == device_request_generation |
|
| 1148 |
+ && self.current_developer_team_id(cx).as_deref() == Some(team_id) |
|
| 1149 |
+ } |
|
| 1150 |
+ |
|
| 1151 |
+ fn begin_developer_device_request(&mut self, team_id: &str) -> u64 {
|
|
| 1152 |
+ self.developer_devices_request_generation = |
|
| 1153 |
+ self.developer_devices_request_generation.wrapping_add(1); |
|
| 1154 |
+ self.developer_devices_team_id = Some(team_id.to_string()); |
|
| 1155 |
+ self.developer_devices_loading = true; |
|
| 1156 |
+ self.developer_devices_error = None; |
|
| 1157 |
+ self.developer_devices_request_generation |
|
| 1158 |
+ } |
|
| 1159 |
+ |
|
| 1160 |
+ fn apply_developer_device_result( |
|
| 1161 |
+ &mut self, |
|
| 1162 |
+ result: Result<Vec<DeveloperDeviceOption>, AppError>, |
|
| 1163 |
+ team_id: String, |
|
| 1164 |
+ cx: &mut Context<Self>, |
|
| 1165 |
+ ) {
|
|
| 1166 |
+ self.developer_devices_loading = false; |
|
| 1167 |
+ self.developer_devices_team_id = Some(team_id); |
|
| 1168 |
+ match result {
|
|
| 1169 |
+ Ok(devices) => self.developer_devices = devices, |
|
| 1170 |
+ Err(error) => self.developer_devices_error = Some(error.user_message().into()), |
|
| 1171 |
+ } |
|
| 1172 |
+ cx.notify(); |
|
| 1173 |
+ } |
|
| 1174 |
+ |
|
| 1175 |
+ fn load_developer_devices(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
| 1176 |
+ if !matches!(self.mode, SettingsMode::Team) {
|
|
| 1177 |
+ return; |
|
| 1178 |
+ } |
|
| 1179 |
+ let Some(team_id) = self.current_developer_team_id(cx) else {
|
|
| 1180 |
+ self.developer_devices_request_generation = |
|
| 1181 |
+ self.developer_devices_request_generation.wrapping_add(1); |
|
| 1182 |
+ self.developer_devices.clear(); |
|
| 1183 |
+ self.developer_devices_team_id = None; |
|
| 1184 |
+ self.developer_devices_loading = false; |
|
| 1185 |
+ self.developer_devices_error = None; |
|
| 1186 |
+ cx.notify(); |
|
| 1187 |
+ return; |
|
| 1188 |
+ }; |
|
| 1189 |
+ if self.developer_devices_loading |
|
| 1190 |
+ && self.developer_devices_team_id.as_deref() == Some(team_id.as_str()) |
|
| 1191 |
+ {
|
|
| 1192 |
+ return; |
|
| 1193 |
+ } |
|
| 1194 |
+ if self.developer_devices_loading {
|
|
| 1195 |
+ self.developer_devices_request_generation = |
|
| 1196 |
+ self.developer_devices_request_generation.wrapping_add(1); |
|
| 1197 |
+ self.developer_devices_loading = false; |
|
| 1198 |
+ self.developer_devices.clear(); |
|
| 1199 |
+ self.developer_devices_team_id = Some(team_id.clone()); |
|
| 1200 |
+ } |
|
| 1201 |
+ let developer_context = match self.selected_developer_context(cx) {
|
|
| 1202 |
+ Ok(context) => context, |
|
| 1203 |
+ Err(error) => {
|
|
| 1204 |
+ self.developer_devices_request_generation = |
|
| 1205 |
+ self.developer_devices_request_generation.wrapping_add(1); |
|
| 1206 |
+ self.developer_devices.clear(); |
|
| 1207 |
+ self.developer_devices_team_id = Some(team_id); |
|
| 1208 |
+ self.developer_devices_loading = false; |
|
| 1209 |
+ self.developer_devices_error = Some(error.into()); |
|
| 1210 |
+ cx.notify(); |
|
| 1211 |
+ return; |
|
| 1212 |
+ } |
|
| 1213 |
+ }; |
|
| 1214 |
+ |
|
| 1215 |
+ let task_generation = self.task_generation(); |
|
| 1216 |
+ let task_mode = self.task_mode(); |
|
| 1217 |
+ let device_request_generation = self.begin_developer_device_request(&team_id); |
|
| 1218 |
+ self.developer_devices.clear(); |
|
| 1219 |
+ let team_id_for_operation = team_id.clone(); |
|
| 1220 |
+ cx.spawn_in(window, async move |settings, cx| {
|
|
| 1221 |
+ let result = cx |
|
| 1222 |
+ .background_spawn(async move {
|
|
| 1223 |
+ app_effects::list_developer_devices(developer_context, team_id_for_operation) |
|
| 1224 |
+ .await |
|
| 1225 |
+ }) |
|
| 1226 |
+ .await; |
|
| 1227 |
+ let _ = settings.update_in(cx, |settings, _, cx| {
|
|
| 1228 |
+ if !settings.accepts_developer_device_result( |
|
| 1229 |
+ task_generation, |
|
| 1230 |
+ &task_mode, |
|
| 1231 |
+ device_request_generation, |
|
| 1232 |
+ &team_id, |
|
| 1233 |
+ cx, |
|
| 1234 |
+ ) {
|
|
| 1235 |
+ return; |
|
| 1236 |
+ } |
|
| 1237 |
+ settings.apply_developer_device_result(result, team_id, cx); |
|
| 1238 |
+ }); |
|
| 1239 |
+ }) |
|
| 1240 |
+ .detach(); |
|
| 1241 |
+ cx.notify(); |
|
| 1242 |
+ } |
|
| 1243 |
+ |
|
| 1244 |
+ fn set_developer_device_add_popover( |
|
| 1245 |
+ &mut self, |
|
| 1246 |
+ open: bool, |
|
| 1247 |
+ window: &mut Window, |
|
| 1248 |
+ cx: &mut Context<Self>, |
|
| 1249 |
+ ) {
|
|
| 1250 |
+ window.focus(&self.focus_handle, cx); |
|
| 1251 |
+ if self.developer_devices_loading || self.team_refreshing {
|
|
| 1252 |
+ return; |
|
| 1253 |
+ } |
|
| 1254 |
+ if !open {
|
|
| 1255 |
+ self.developer_device_add_form = None; |
|
| 1256 |
+ cx.notify(); |
|
| 1257 |
+ return; |
|
| 1258 |
+ } |
|
| 1259 |
+ let selected_device = self |
|
| 1260 |
+ .team_snapshot(cx) |
|
| 1261 |
+ .and_then(|snapshot| snapshot.selected_device); |
|
| 1262 |
+ let Some(team_id) = self.current_developer_team_id(cx) else {
|
|
| 1263 |
+ self.developer_devices_error = Some("No developer team is selected.".into());
|
|
| 1264 |
+ cx.notify(); |
|
| 1265 |
+ return; |
|
| 1266 |
+ }; |
|
| 1267 |
+ let (default_name, default_udid) = developer_device_form_defaults(selected_device.as_ref()); |
|
| 1268 |
+ let name = cx.new(|cx| {
|
|
| 1269 |
+ InputState::new(window, cx) |
|
| 1270 |
+ .default_value(default_name) |
|
| 1271 |
+ .placeholder("Development iPhone")
|
|
| 1272 |
+ .clean_on_escape() |
|
| 1273 |
+ }); |
|
| 1274 |
+ let udid = cx.new(|cx| {
|
|
| 1275 |
+ InputState::new(window, cx) |
|
| 1276 |
+ .default_value(default_udid) |
|
| 1277 |
+ .placeholder("0000000000000000000000000000000000000000")
|
|
| 1278 |
+ .clean_on_escape() |
|
| 1279 |
+ }); |
|
| 1280 |
+ self.developer_device_add_form = Some(DeveloperDeviceAddForm {
|
|
| 1281 |
+ team_id, |
|
| 1282 |
+ name: name.clone(), |
|
| 1283 |
+ udid, |
|
| 1284 |
+ }); |
|
| 1285 |
+ self.developer_devices_error = None; |
|
| 1286 |
+ name.update(cx, |name, cx| name.focus(window, cx)); |
|
| 1287 |
+ cx.notify(); |
|
| 1288 |
+ } |
|
| 1289 |
+ |
|
| 1290 |
+ fn cancel_developer_device_add( |
|
| 1291 |
+ &mut self, |
|
| 1292 |
+ _: &ClickEvent, |
|
| 1293 |
+ window: &mut Window, |
|
| 1294 |
+ cx: &mut Context<Self>, |
|
| 1295 |
+ ) {
|
|
| 1296 |
+ window.focus(&self.focus_handle, cx); |
|
| 1297 |
+ self.developer_device_add_form = None; |
|
| 1298 |
+ cx.notify(); |
|
| 1299 |
+ } |
|
| 1300 |
+ |
|
| 1301 |
+ fn submit_developer_device_add( |
|
| 1302 |
+ &mut self, |
|
| 1303 |
+ _: &ClickEvent, |
|
| 1304 |
+ window: &mut Window, |
|
| 1305 |
+ cx: &mut Context<Self>, |
|
| 1306 |
+ ) {
|
|
| 1307 |
+ window.focus(&self.focus_handle, cx); |
|
| 1308 |
+ if self.developer_devices_loading || self.team_refreshing {
|
|
| 1309 |
+ return; |
|
| 1310 |
+ } |
|
| 1311 |
+ let Some(form) = self.developer_device_add_form.clone() else {
|
|
| 1312 |
+ return; |
|
| 1313 |
+ }; |
|
| 1314 |
+ if self.current_developer_team_id(cx).as_deref() != Some(form.team_id.as_str()) {
|
|
| 1315 |
+ self.developer_device_add_form = None; |
|
| 1316 |
+ self.developer_devices_error = |
|
| 1317 |
+ Some("The selected developer team changed. Add the device again.".into());
|
|
| 1318 |
+ cx.notify(); |
|
| 1319 |
+ return; |
|
| 1320 |
+ } |
|
| 1321 |
+ let name = form.name.read(cx).value().trim().to_string(); |
|
| 1322 |
+ let udid = form.udid.read(cx).value().trim().to_string(); |
|
| 1323 |
+ if name.is_empty() {
|
|
| 1324 |
+ self.developer_devices_error = Some("Enter a device name.".into());
|
|
| 1325 |
+ cx.notify(); |
|
| 1326 |
+ return; |
|
| 1327 |
+ } |
|
| 1328 |
+ if udid.is_empty() {
|
|
| 1329 |
+ self.developer_devices_error = Some("Enter a device UDID.".into());
|
|
| 1330 |
+ cx.notify(); |
|
| 1331 |
+ return; |
|
| 1332 |
+ } |
|
| 1333 |
+ let developer_context = match self.selected_developer_context(cx) {
|
|
| 1334 |
+ Ok(context) => context, |
|
| 1335 |
+ Err(error) => {
|
|
| 1336 |
+ self.developer_devices_error = Some(error.into()); |
|
| 1337 |
+ cx.notify(); |
|
| 1338 |
+ return; |
|
| 1339 |
+ } |
|
| 1340 |
+ }; |
|
| 1341 |
+ |
|
| 1342 |
+ let team_id = form.team_id; |
|
| 1343 |
+ let task_generation = self.task_generation(); |
|
| 1344 |
+ let task_mode = self.task_mode(); |
|
| 1345 |
+ let device_request_generation = self.begin_developer_device_request(&team_id); |
|
| 1346 |
+ self.developer_device_add_form = None; |
|
| 1347 |
+ let team_id_for_operation = team_id.clone(); |
|
| 1348 |
+ cx.spawn_in(window, async move |settings, cx| {
|
|
| 1349 |
+ let result = cx |
|
| 1350 |
+ .background_spawn(async move {
|
|
| 1351 |
+ app_effects::add_developer_device( |
|
| 1352 |
+ developer_context, |
|
| 1353 |
+ team_id_for_operation, |
|
| 1354 |
+ name, |
|
| 1355 |
+ udid, |
|
| 1356 |
+ ) |
|
| 1357 |
+ .await |
|
| 1358 |
+ }) |
|
| 1359 |
+ .await; |
|
| 1360 |
+ let _ = settings.update_in(cx, |settings, _, cx| {
|
|
| 1361 |
+ if !settings.accepts_developer_device_result( |
|
| 1362 |
+ task_generation, |
|
| 1363 |
+ &task_mode, |
|
| 1364 |
+ device_request_generation, |
|
| 1365 |
+ &team_id, |
|
| 1366 |
+ cx, |
|
| 1367 |
+ ) {
|
|
| 1368 |
+ return; |
|
| 1369 |
+ } |
|
| 1370 |
+ settings.apply_developer_device_result(result, team_id, cx); |
|
| 1371 |
+ }); |
|
| 1372 |
+ }) |
|
| 1373 |
+ .detach(); |
|
| 1374 |
+ cx.notify(); |
|
| 1375 |
+ } |
|
| 1376 |
+ |
|
| 1377 |
+ fn remove_developer_device( |
|
| 1378 |
+ &mut self, |
|
| 1379 |
+ device_id: String, |
|
| 1380 |
+ _: &ClickEvent, |
|
| 1381 |
+ window: &mut Window, |
|
| 1382 |
+ cx: &mut Context<Self>, |
|
| 1383 |
+ ) {
|
|
| 1384 |
+ window.focus(&self.focus_handle, cx); |
|
| 1385 |
+ if self.developer_devices_loading || self.team_refreshing {
|
|
| 1386 |
+ return; |
|
| 1387 |
+ } |
|
| 1388 |
+ let Some(team_id) = self.current_developer_team_id(cx) else {
|
|
| 1389 |
+ self.developer_devices_error = Some("No developer team is selected.".into());
|
|
| 1390 |
+ cx.notify(); |
|
| 1391 |
+ return; |
|
| 1392 |
+ }; |
|
| 1393 |
+ if device_id.trim().is_empty() {
|
|
| 1394 |
+ self.developer_devices_error = |
|
| 1395 |
+ Some("The selected device has no portal identifier.".into());
|
|
| 1396 |
+ cx.notify(); |
|
| 1397 |
+ return; |
|
| 1398 |
+ } |
|
| 1399 |
+ let developer_context = match self.selected_developer_context(cx) {
|
|
| 1400 |
+ Ok(context) => context, |
|
| 1401 |
+ Err(error) => {
|
|
| 1402 |
+ self.developer_devices_error = Some(error.into()); |
|
| 1403 |
+ cx.notify(); |
|
| 1404 |
+ return; |
|
| 1405 |
+ } |
|
| 1406 |
+ }; |
|
| 1407 |
+ |
|
| 1408 |
+ let task_generation = self.task_generation(); |
|
| 1409 |
+ let task_mode = self.task_mode(); |
|
| 1410 |
+ let device_request_generation = self.begin_developer_device_request(&team_id); |
|
| 1411 |
+ let team_id_for_operation = team_id.clone(); |
|
| 1412 |
+ cx.spawn_in(window, async move |settings, cx| {
|
|
| 1413 |
+ let result = cx |
|
| 1414 |
+ .background_spawn(async move {
|
|
| 1415 |
+ app_effects::delete_developer_device( |
|
| 1416 |
+ developer_context, |
|
| 1417 |
+ team_id_for_operation, |
|
| 1418 |
+ device_id, |
|
| 1419 |
+ ) |
|
| 1420 |
+ .await |
|
| 1421 |
+ }) |
|
| 1422 |
+ .await; |
|
| 1423 |
+ let _ = settings.update_in(cx, |settings, _, cx| {
|
|
| 1424 |
+ if !settings.accepts_developer_device_result( |
|
| 1425 |
+ task_generation, |
|
| 1426 |
+ &task_mode, |
|
| 1427 |
+ device_request_generation, |
|
| 1428 |
+ &team_id, |
|
| 1429 |
+ cx, |
|
| 1430 |
+ ) {
|
|
| 1431 |
+ return; |
|
| 1432 |
+ } |
|
| 1433 |
+ settings.apply_developer_device_result(result, team_id, cx); |
|
| 1434 |
+ }); |
|
| 1435 |
+ }) |
|
| 1436 |
+ .detach(); |
|
| 1078 | 1437 |
cx.notify(); |
| 1079 | 1438 |
} |
| 1080 | 1439 |
|
| ... | ... |
@@ -1238,6 +1597,11 @@ impl SettingsWindow {
|
| 1238 | 1597 |
self.app_id_picker_open = false; |
| 1239 | 1598 |
self.app_id_add_form = None; |
| 1240 | 1599 |
self.app_id_edit_form = None; |
| 1600 |
+ self.developer_device_add_form = None; |
|
| 1601 |
+ self.developer_devices_request_generation = |
|
| 1602 |
+ self.developer_devices_request_generation.wrapping_add(1); |
|
| 1603 |
+ self.developer_devices_loading = false; |
|
| 1604 |
+ self.developer_devices_error = None; |
|
| 1241 | 1605 |
self.team_refreshing = true; |
| 1242 | 1606 |
self.team_refresh_error = None; |
| 1243 | 1607 |
self.certificate_error = None; |
| ... | ... |
@@ -3062,6 +3426,7 @@ mod tests {
|
| 3062 | 3426 |
"16.0", |
| 3063 | 3427 |
vec![SupportedDeviceFamily::IPhone], |
| 3064 | 3428 |
), |
| 3429 |
+ nested_bundles: Vec::new(), |
|
| 3065 | 3430 |
path: format!("/tmp/{name}.ipa"),
|
| 3066 | 3431 |
icon_path: None, |
| 3067 | 3432 |
icon_override_path: None, |
| ... | ... |
@@ -3092,6 +3457,7 @@ mod tests {
|
| 3092 | 3457 |
serial_number: "SERIAL".to_string(), |
| 3093 | 3458 |
machine_name: "Mac".to_string(), |
| 3094 | 3459 |
private_key_available: true, |
| 3460 |
+ certificate_fingerprint: Some("certificate-fingerprint".to_string()),
|
|
| 3095 | 3461 |
public_key_fingerprint: Some("fingerprint".to_string()),
|
| 3096 | 3462 |
}], |
| 3097 | 3463 |
} |
| ... | ... |
@@ -3166,6 +3532,26 @@ mod tests {
|
| 3166 | 3532 |
)); |
| 3167 | 3533 |
} |
| 3168 | 3534 |
|
| 3535 |
+ #[test] |
|
| 3536 |
+ fn developer_device_form_uses_selected_device_defaults() {
|
|
| 3537 |
+ let device = DeviceOption {
|
|
| 3538 |
+ name: "Development iPhone".to_string(), |
|
| 3539 |
+ model: "iPhone".to_string(), |
|
| 3540 |
+ os: "18.0".to_string(), |
|
| 3541 |
+ udid: "00008110-001234567890001E".to_string(), |
|
| 3542 |
+ connection: "USB".to_string(), |
|
| 3543 |
+ }; |
|
| 3544 |
+ |
|
| 3545 |
+ assert_eq!( |
|
| 3546 |
+ developer_device_form_defaults(Some(&device)), |
|
| 3547 |
+ (device.name.clone(), device.udid.clone()) |
|
| 3548 |
+ ); |
|
| 3549 |
+ assert_eq!( |
|
| 3550 |
+ developer_device_form_defaults(None), |
|
| 3551 |
+ (String::new(), String::new()) |
|
| 3552 |
+ ); |
|
| 3553 |
+ } |
|
| 3554 |
+ |
|
| 3169 | 3555 |
#[test] |
| 3170 | 3556 |
fn settings_snapshots_are_derived_from_latest_parent_state() {
|
| 3171 | 3557 |
let mut state = sample_state(); |
| ... | ... |
@@ -3208,7 +3594,7 @@ impl Render for SettingsWindow {
|
| 3208 | 3594 |
let theme_preference = self.theme_preference(cx); |
| 3209 | 3595 |
sync_window_theme(window, cx, theme_preference); |
| 3210 | 3596 |
|
| 3211 |
- if self.adi_operation.is_some() || self.team_refreshing {
|
|
| 3597 |
+ if self.adi_operation.is_some() || self.team_refreshing || self.developer_devices_loading {
|
|
| 3212 | 3598 |
self.spinner_turns = (self.spinner_turns + 0.035) % 1.; |
| 3213 | 3599 |
window.request_animation_frame(); |
| 3214 | 3600 |
} else {
|
| ... | ... |
@@ -3231,6 +3617,10 @@ impl Render for SettingsWindow {
|
| 3231 | 3617 |
let app_settings_error = self.app_settings_error.clone(); |
| 3232 | 3618 |
let app_id_add_form = self.app_id_add_form.clone(); |
| 3233 | 3619 |
let app_id_edit_form = self.app_id_edit_form.clone(); |
| 3620 |
+ let developer_device_add_form = self.developer_device_add_form.clone(); |
|
| 3621 |
+ let developer_devices = self.developer_devices.clone(); |
|
| 3622 |
+ let developer_devices_loading = self.developer_devices_loading; |
|
| 3623 |
+ let developer_devices_error = self.developer_devices_error.clone(); |
|
| 3234 | 3624 |
let selected_entitlement = self.selected_entitlement; |
| 3235 | 3625 |
let entitlement_edit = self.entitlement_edit.clone(); |
| 3236 | 3626 |
let entitlement_type_picker_open = self.entitlement_type_picker_open; |
| ... | ... |
@@ -3257,6 +3647,10 @@ impl Render for SettingsWindow {
|
| 3257 | 3647 |
app_id_picker_open, |
| 3258 | 3648 |
app_id_add_form: app_id_add_form.as_ref(), |
| 3259 | 3649 |
app_id_edit_form: app_id_edit_form.as_ref(), |
| 3650 |
+ developer_devices: &developer_devices, |
|
| 3651 |
+ developer_devices_loading, |
|
| 3652 |
+ developer_device_add_form: developer_device_add_form.as_ref(), |
|
| 3653 |
+ developer_devices_error, |
|
| 3260 | 3654 |
team_refreshing, |
| 3261 | 3655 |
team_refresh_error, |
| 3262 | 3656 |
certificate_error, |
| ... | ... |
@@ -70,11 +70,14 @@ pub(super) fn render(props: AppViewProps<'_>, cx: &mut Context<SettingsWindow>) |
| 70 | 70 |
app_detail_edit, |
| 71 | 71 |
cx, |
| 72 | 72 |
)) |
| 73 |
- .child(app_detail_row( |
|
| 73 |
+ .child(app_detail_row_with_display_value( |
|
| 74 | 74 |
AppMetadataField::BundleId, |
| 75 | 75 |
app, |
| 76 | 76 |
true, |
| 77 | 77 |
app_detail_edit, |
| 78 |
+ crate::app::entitlements::effective_bundle_identifier_for_app( |
|
| 79 |
+ app, team_id, |
|
| 80 |
+ ), |
|
| 78 | 81 |
cx, |
| 79 | 82 |
)) |
| 80 | 83 |
.child(app_detail_row( |
| ... | ... |
@@ -210,9 +213,34 @@ fn app_detail_row( |
| 210 | 213 |
editable: bool, |
| 211 | 214 |
edit: Option<&AppDetailEdit>, |
| 212 | 215 |
cx: &mut Context<SettingsWindow>, |
| 216 |
+) -> gpui::Div {
|
|
| 217 |
+ app_detail_row_inner(field, app, editable, edit, None, cx) |
|
| 218 |
+} |
|
| 219 |
+ |
|
| 220 |
+fn app_detail_row_with_display_value( |
|
| 221 |
+ field: AppMetadataField, |
|
| 222 |
+ app: &AppOption, |
|
| 223 |
+ editable: bool, |
|
| 224 |
+ edit: Option<&AppDetailEdit>, |
|
| 225 |
+ display_value: String, |
|
| 226 |
+ cx: &mut Context<SettingsWindow>, |
|
| 227 |
+) -> gpui::Div {
|
|
| 228 |
+ app_detail_row_inner(field, app, editable, edit, Some(display_value), cx) |
|
| 229 |
+} |
|
| 230 |
+ |
|
| 231 |
+fn app_detail_row_inner( |
|
| 232 |
+ field: AppMetadataField, |
|
| 233 |
+ app: &AppOption, |
|
| 234 |
+ editable: bool, |
|
| 235 |
+ edit: Option<&AppDetailEdit>, |
|
| 236 |
+ display_value: Option<String>, |
|
| 237 |
+ cx: &mut Context<SettingsWindow>, |
|
| 213 | 238 |
) -> gpui::Div {
|
| 214 | 239 |
let is_editing = edit.is_some_and(|edit| edit.field() == field); |
| 215 | 240 |
let changed = app.field_is_overridden(field); |
| 241 |
+ let effective_changed = display_value |
|
| 242 |
+ .as_deref() |
|
| 243 |
+ .is_some_and(|value| value != app.field_display_value(field)); |
|
| 216 | 244 |
|
| 217 | 245 |
div() |
| 218 | 246 |
.min_w_0() |
| ... | ... |
@@ -241,9 +269,13 @@ fn app_detail_row( |
| 241 | 269 |
.min_w_0() |
| 242 | 270 |
.flex_1() |
| 243 | 271 |
.text_sm() |
| 244 |
- .text_color(rgb(if changed { 0x173f45 } else { 0x405057 }))
|
|
| 272 |
+ .text_color(rgb(if changed || effective_changed {
|
|
| 273 |
+ 0x173f45 |
|
| 274 |
+ } else {
|
|
| 275 |
+ 0x405057 |
|
| 276 |
+ })) |
|
| 245 | 277 |
.text_ellipsis() |
| 246 |
- .child(app.field_display_value(field)) |
|
| 278 |
+ .child(display_value.unwrap_or_else(|| app.field_display_value(field))) |
|
| 247 | 279 |
}, |
| 248 | 280 |
) |
| 249 | 281 |
.when(editable, |this| {
|
| ... | ... |
@@ -14,6 +14,10 @@ pub(super) struct DeveloperViewProps<'a> {
|
| 14 | 14 |
pub(super) app_id_picker_open: bool, |
| 15 | 15 |
pub(super) app_id_add_form: Option<&'a AppIdAddForm>, |
| 16 | 16 |
pub(super) app_id_edit_form: Option<&'a AppIdEditForm>, |
| 17 |
+ pub(super) developer_devices: &'a [DeveloperDeviceOption], |
|
| 18 |
+ pub(super) developer_devices_loading: bool, |
|
| 19 |
+ pub(super) developer_device_add_form: Option<&'a DeveloperDeviceAddForm>, |
|
| 20 |
+ pub(super) developer_devices_error: Option<SharedString>, |
|
| 17 | 21 |
pub(super) team_refreshing: bool, |
| 18 | 22 |
pub(super) team_refresh_error: Option<SharedString>, |
| 19 | 23 |
pub(super) certificate_error: Option<SharedString>, |
| ... | ... |
@@ -34,6 +38,10 @@ pub(super) fn render(props: DeveloperViewProps<'_>, cx: &mut Context<SettingsWin |
| 34 | 38 |
app_id_picker_open, |
| 35 | 39 |
app_id_add_form, |
| 36 | 40 |
app_id_edit_form, |
| 41 |
+ developer_devices, |
|
| 42 |
+ developer_devices_loading, |
|
| 43 |
+ developer_device_add_form, |
|
| 44 |
+ developer_devices_error, |
|
| 37 | 45 |
team_refreshing, |
| 38 | 46 |
team_refresh_error, |
| 39 | 47 |
certificate_error, |
| ... | ... |
@@ -69,6 +77,17 @@ pub(super) fn render(props: DeveloperViewProps<'_>, cx: &mut Context<SettingsWin |
| 69 | 77 |
}, |
| 70 | 78 |
cx, |
| 71 | 79 |
)); |
| 80 |
+ content = content.child(developer_device_section( |
|
| 81 |
+ DeveloperDeviceSectionProps {
|
|
| 82 |
+ team, |
|
| 83 |
+ devices: developer_devices, |
|
| 84 |
+ loading: developer_devices_loading, |
|
| 85 |
+ team_refreshing, |
|
| 86 |
+ add_form: developer_device_add_form, |
|
| 87 |
+ operation_error: developer_devices_error, |
|
| 88 |
+ }, |
|
| 89 |
+ cx, |
|
| 90 |
+ )); |
|
| 72 | 91 |
content = content.child(developer_logout_button(cx)); |
| 73 | 92 |
|
| 74 | 93 |
settings_window_shell() |
| ... | ... |
@@ -493,14 +512,11 @@ fn missing_private_key_warning( |
| 493 | 512 |
.text_xs() |
| 494 | 513 |
.font_weight(FontWeight::SEMIBOLD) |
| 495 | 514 |
.text_color(rgb(0x7a5613)) |
| 496 |
- .child("Private key missing"),
|
|
| 515 |
+ .child("Managed private key missing"),
|
|
| 497 | 516 |
) |
| 498 |
- .child( |
|
| 499 |
- div() |
|
| 500 |
- .text_xs() |
|
| 501 |
- .text_color(rgb(0x7a6a50)) |
|
| 502 |
- .child("Import the matching private key to use this certificate."),
|
|
| 503 |
- ), |
|
| 517 |
+ .child(div().text_xs().text_color(rgb(0x7a6a50)).child( |
|
| 518 |
+ "Import the matching PEM key for Super Sideloader to use this certificate.", |
|
| 519 |
+ )), |
|
| 504 | 520 |
) |
| 505 | 521 |
.child( |
| 506 | 522 |
surface_button("import-certificate-key")
|
| ... | ... |
@@ -1117,6 +1133,257 @@ fn app_id_icon_button( |
| 1117 | 1133 |
.child(icon_button_surface(icon, 0xebf1f0, 0xdfe8e6, 0x53666d)) |
| 1118 | 1134 |
} |
| 1119 | 1135 |
|
| 1136 |
+struct DeveloperDeviceSectionProps<'a> {
|
|
| 1137 |
+ team: Option<&'a TeamOption>, |
|
| 1138 |
+ devices: &'a [DeveloperDeviceOption], |
|
| 1139 |
+ loading: bool, |
|
| 1140 |
+ team_refreshing: bool, |
|
| 1141 |
+ add_form: Option<&'a DeveloperDeviceAddForm>, |
|
| 1142 |
+ operation_error: Option<SharedString>, |
|
| 1143 |
+} |
|
| 1144 |
+ |
|
| 1145 |
+fn developer_device_section( |
|
| 1146 |
+ props: DeveloperDeviceSectionProps<'_>, |
|
| 1147 |
+ cx: &mut Context<SettingsWindow>, |
|
| 1148 |
+) -> gpui::Div {
|
|
| 1149 |
+ let DeveloperDeviceSectionProps {
|
|
| 1150 |
+ team, |
|
| 1151 |
+ devices, |
|
| 1152 |
+ loading, |
|
| 1153 |
+ team_refreshing, |
|
| 1154 |
+ add_form, |
|
| 1155 |
+ operation_error, |
|
| 1156 |
+ } = props; |
|
| 1157 |
+ let disabled = team.is_none() || loading || team_refreshing; |
|
| 1158 |
+ |
|
| 1159 |
+ developer_settings_section( |
|
| 1160 |
+ "Devices", |
|
| 1161 |
+ "Registered development devices available to provisioning profiles.", |
|
| 1162 |
+ div() |
|
| 1163 |
+ .flex() |
|
| 1164 |
+ .flex_col() |
|
| 1165 |
+ .gap_3() |
|
| 1166 |
+ .child( |
|
| 1167 |
+ div() |
|
| 1168 |
+ .flex() |
|
| 1169 |
+ .items_center() |
|
| 1170 |
+ .justify_between() |
|
| 1171 |
+ .gap_3() |
|
| 1172 |
+ .child( |
|
| 1173 |
+ div() |
|
| 1174 |
+ .text_xs() |
|
| 1175 |
+ .text_color(rgb(0x6a7a81)) |
|
| 1176 |
+ .child(match devices.len() {
|
|
| 1177 |
+ 0 => "No devices".to_string(), |
|
| 1178 |
+ 1 => "1 registered device".to_string(), |
|
| 1179 |
+ count => format!("{count} registered devices"),
|
|
| 1180 |
+ }), |
|
| 1181 |
+ ) |
|
| 1182 |
+ .child(developer_device_add_button(disabled, add_form, cx)), |
|
| 1183 |
+ ) |
|
| 1184 |
+ .when(loading, |this| {
|
|
| 1185 |
+ this.child( |
|
| 1186 |
+ div() |
|
| 1187 |
+ .rounded_md() |
|
| 1188 |
+ .border_1() |
|
| 1189 |
+ .border_color(rgb(0xd8e0df)) |
|
| 1190 |
+ .bg(rgb(0xf6f9f9)) |
|
| 1191 |
+ .p_3() |
|
| 1192 |
+ .text_xs() |
|
| 1193 |
+ .text_color(rgb(0x6a7a81)) |
|
| 1194 |
+ .child("Loading registered devices..."),
|
|
| 1195 |
+ ) |
|
| 1196 |
+ }) |
|
| 1197 |
+ .when(!loading && team.is_some() && devices.is_empty(), |this| {
|
|
| 1198 |
+ this.child(empty_state( |
|
| 1199 |
+ "No development devices are registered for this team.", |
|
| 1200 |
+ )) |
|
| 1201 |
+ }) |
|
| 1202 |
+ .when(!loading && team.is_some(), |this| {
|
|
| 1203 |
+ this.children( |
|
| 1204 |
+ devices |
|
| 1205 |
+ .iter() |
|
| 1206 |
+ .enumerate() |
|
| 1207 |
+ .map(|(index, device)| developer_device_row(device, index, disabled, cx)), |
|
| 1208 |
+ ) |
|
| 1209 |
+ }) |
|
| 1210 |
+ .when(team.is_none(), |this| {
|
|
| 1211 |
+ this.child(disabled_select_placeholder( |
|
| 1212 |
+ "No developer team", |
|
| 1213 |
+ "Load a developer team first.", |
|
| 1214 |
+ "Device management unavailable", |
|
| 1215 |
+ )) |
|
| 1216 |
+ }) |
|
| 1217 |
+ .when_some(operation_error, |this, error| {
|
|
| 1218 |
+ this.child(refresh_error(error)) |
|
| 1219 |
+ }), |
|
| 1220 |
+ ) |
|
| 1221 |
+} |
|
| 1222 |
+ |
|
| 1223 |
+fn developer_device_add_button( |
|
| 1224 |
+ disabled: bool, |
|
| 1225 |
+ form: Option<&DeveloperDeviceAddForm>, |
|
| 1226 |
+ cx: &mut Context<SettingsWindow>, |
|
| 1227 |
+) -> AnyElement {
|
|
| 1228 |
+ let trigger = surface_button("begin-developer-device-add")
|
|
| 1229 |
+ .h_7() |
|
| 1230 |
+ .child(action_button_surface( |
|
| 1231 |
+ "Add Device", |
|
| 1232 |
+ 0xebf1f0, |
|
| 1233 |
+ 0xdfe8e6, |
|
| 1234 |
+ 0x53666d, |
|
| 1235 |
+ Some(lucide_icon_tinted("icons/plus.svg", 0x53666d)),
|
|
| 1236 |
+ )); |
|
| 1237 |
+ |
|
| 1238 |
+ if disabled {
|
|
| 1239 |
+ return trigger.opacity(0.45).tab_stop(false).into_any_element(); |
|
| 1240 |
+ } |
|
| 1241 |
+ |
|
| 1242 |
+ floating_select_popover( |
|
| 1243 |
+ "developer-device-add-popover-scroll", |
|
| 1244 |
+ trigger.cursor_pointer(), |
|
| 1245 |
+ form.is_some(), |
|
| 1246 |
+ cx.listener(|this, open: &bool, window, cx| {
|
|
| 1247 |
+ this.set_developer_device_add_popover(*open, window, cx); |
|
| 1248 |
+ }), |
|
| 1249 |
+ FloatingPopoverLayout {
|
|
| 1250 |
+ width: 420., |
|
| 1251 |
+ max_height: 260., |
|
| 1252 |
+ offset_y: 36., |
|
| 1253 |
+ }, |
|
| 1254 |
+ developer_device_add_popover(form, cx), |
|
| 1255 |
+ ) |
|
| 1256 |
+ .into_any_element() |
|
| 1257 |
+} |
|
| 1258 |
+ |
|
| 1259 |
+fn developer_device_add_popover( |
|
| 1260 |
+ form: Option<&DeveloperDeviceAddForm>, |
|
| 1261 |
+ cx: &mut Context<SettingsWindow>, |
|
| 1262 |
+) -> impl IntoElement {
|
|
| 1263 |
+ let Some(form) = form else {
|
|
| 1264 |
+ return div() |
|
| 1265 |
+ .p_3() |
|
| 1266 |
+ .text_xs() |
|
| 1267 |
+ .text_color(rgb(0x6a7a81)) |
|
| 1268 |
+ .child("Preparing device...");
|
|
| 1269 |
+ }; |
|
| 1270 |
+ |
|
| 1271 |
+ div() |
|
| 1272 |
+ .p_3() |
|
| 1273 |
+ .flex() |
|
| 1274 |
+ .flex_col() |
|
| 1275 |
+ .gap_3() |
|
| 1276 |
+ .child( |
|
| 1277 |
+ div() |
|
| 1278 |
+ .flex() |
|
| 1279 |
+ .flex_col() |
|
| 1280 |
+ .gap_1() |
|
| 1281 |
+ .child(settings_label("Name"))
|
|
| 1282 |
+ .child(Input::new(&form.name).small()), |
|
| 1283 |
+ ) |
|
| 1284 |
+ .child( |
|
| 1285 |
+ div() |
|
| 1286 |
+ .flex() |
|
| 1287 |
+ .flex_col() |
|
| 1288 |
+ .gap_1() |
|
| 1289 |
+ .child(settings_label("UDID"))
|
|
| 1290 |
+ .child(Input::new(&form.udid).small()), |
|
| 1291 |
+ ) |
|
| 1292 |
+ .child( |
|
| 1293 |
+ div() |
|
| 1294 |
+ .flex() |
|
| 1295 |
+ .items_center() |
|
| 1296 |
+ .justify_end() |
|
| 1297 |
+ .gap_2() |
|
| 1298 |
+ .child( |
|
| 1299 |
+ surface_button("cancel-developer-device-add")
|
|
| 1300 |
+ .h_7() |
|
| 1301 |
+ .cursor_pointer() |
|
| 1302 |
+ .on_click(cx.listener(SettingsWindow::cancel_developer_device_add)) |
|
| 1303 |
+ .child(action_button_surface( |
|
| 1304 |
+ "Cancel", |
|
| 1305 |
+ 0xebf1f0, |
|
| 1306 |
+ 0xdfe8e6, |
|
| 1307 |
+ 0x53666d, |
|
| 1308 |
+ None::<gpui::Div>, |
|
| 1309 |
+ )), |
|
| 1310 |
+ ) |
|
| 1311 |
+ .child( |
|
| 1312 |
+ surface_button("confirm-developer-device-add")
|
|
| 1313 |
+ .h_7() |
|
| 1314 |
+ .cursor_pointer() |
|
| 1315 |
+ .on_click(cx.listener(SettingsWindow::submit_developer_device_add)) |
|
| 1316 |
+ .child(primary_action_button_surface( |
|
| 1317 |
+ "Register", |
|
| 1318 |
+ Some(lucide_icon_tinted("icons/plus.svg", 0xffffff)),
|
|
| 1319 |
+ )), |
|
| 1320 |
+ ), |
|
| 1321 |
+ ) |
|
| 1322 |
+} |
|
| 1323 |
+ |
|
| 1324 |
+fn developer_device_row( |
|
| 1325 |
+ device: &DeveloperDeviceOption, |
|
| 1326 |
+ index: usize, |
|
| 1327 |
+ disabled: bool, |
|
| 1328 |
+ cx: &mut Context<SettingsWindow>, |
|
| 1329 |
+) -> impl IntoElement {
|
|
| 1330 |
+ let device_id = device.id.clone(); |
|
| 1331 |
+ div() |
|
| 1332 |
+ .min_w_0() |
|
| 1333 |
+ .p_3() |
|
| 1334 |
+ .rounded_md() |
|
| 1335 |
+ .border_1() |
|
| 1336 |
+ .border_color(rgb(0xd8e0df)) |
|
| 1337 |
+ .bg(rgb(0xffffff)) |
|
| 1338 |
+ .flex() |
|
| 1339 |
+ .items_center() |
|
| 1340 |
+ .justify_between() |
|
| 1341 |
+ .gap_3() |
|
| 1342 |
+ .child( |
|
| 1343 |
+ div() |
|
| 1344 |
+ .min_w_0() |
|
| 1345 |
+ .flex_1() |
|
| 1346 |
+ .flex() |
|
| 1347 |
+ .flex_col() |
|
| 1348 |
+ .gap_0p5() |
|
| 1349 |
+ .child( |
|
| 1350 |
+ div() |
|
| 1351 |
+ .text_xs() |
|
| 1352 |
+ .font_weight(FontWeight::SEMIBOLD) |
|
| 1353 |
+ .text_color(rgb(0x24333a)) |
|
| 1354 |
+ .whitespace_normal() |
|
| 1355 |
+ .child(device.name.clone()), |
|
| 1356 |
+ ) |
|
| 1357 |
+ .child( |
|
| 1358 |
+ div() |
|
| 1359 |
+ .text_xs() |
|
| 1360 |
+ .text_color(rgb(0x6a7a81)) |
|
| 1361 |
+ .whitespace_normal() |
|
| 1362 |
+ .child(device.udid.clone()), |
|
| 1363 |
+ ), |
|
| 1364 |
+ ) |
|
| 1365 |
+ .child( |
|
| 1366 |
+ surface_button(("remove-developer-device", index))
|
|
| 1367 |
+ .flex_none() |
|
| 1368 |
+ .w_7() |
|
| 1369 |
+ .h_7() |
|
| 1370 |
+ .when(disabled, |this| this.opacity(0.45).tab_stop(false)) |
|
| 1371 |
+ .when(!disabled, |this| {
|
|
| 1372 |
+ this.cursor_pointer().on_click(cx.listener( |
|
| 1373 |
+ move |settings, event, window, cx| {
|
|
| 1374 |
+ settings.remove_developer_device(device_id.clone(), event, window, cx) |
|
| 1375 |
+ }, |
|
| 1376 |
+ )) |
|
| 1377 |
+ }) |
|
| 1378 |
+ .child(icon_button_surface( |
|
| 1379 |
+ "icons/trash-2.svg", |
|
| 1380 |
+ 0xf4e9e7, |
|
| 1381 |
+ 0xfff7f6, |
|
| 1382 |
+ 0x7d3430, |
|
| 1383 |
+ )), |
|
| 1384 |
+ ) |
|
| 1385 |
+} |
|
| 1386 |
+ |
|
| 1120 | 1387 |
fn settings_app_id_row(app_id: &AppIdOption, index: usize, selected: bool) -> Button {
|
| 1121 | 1388 |
select_option_button( |
| 1122 | 1389 |
("app-id-row", index),
|
| 1123 | 1390 |