use serde::Deserialize; use std::{ collections::{BTreeMap, BTreeSet}, env, error::Error, ffi::OsString, fs::{self, OpenOptions}, io::Write, path::{Path, PathBuf}, }; use url::Url; const BUNDLE_ID: &str = "de.rfc1437.ironstorage"; #[derive(Deserialize)] struct Metadata { app: App, release: Release, screenshots: Vec, watch_screenshots: Vec, content: Content, } #[derive(Deserialize)] struct App { name: String, bundle_identifier: String, developer_name: String, subtitle: String, description: String, keywords: String, icon_file: String, category: String, privacy_url: String, support_url: String, marketing_url: String, age_rating: String, copyright: String, review_notes: String, } #[derive(Deserialize)] struct Release { version: String, build: String, minimum_ios: String, minimum_watchos: String, notes: String, } #[derive(Deserialize)] struct Screenshot { file: String, caption: String, width: u32, height: u32, } #[derive(Deserialize)] struct Content { advertising: bool, account_creation: bool, digital_purchases: bool, tracking: bool, user_generated_content: bool, } #[derive(Deserialize)] struct CargoMetadata { packages: Vec, resolve: CargoResolve, } #[derive(Deserialize)] struct CargoPackage { id: String, name: String, version: String, license: Option, license_file: Option, manifest_path: PathBuf, source: Option, repository: Option, } #[derive(Deserialize)] struct CargoResolve { nodes: Vec, } #[derive(Deserialize)] struct CargoNode { id: String, dependencies: Vec, } fn main() -> Result<(), Box> { let args: Vec = env::args_os().collect(); match args.as_slice() { [_, command, root] if command == "verify" => verify(Path::new(root)), [_, command, metadata, output] if command == "licenses" => { write_licenses(Path::new(metadata), Path::new(output)) } _ => Err("usage:\n ironstorage-apple-release verify \n ironstorage-apple-release licenses ".into()), } } fn read_metadata(root: &Path) -> Result> { Ok(toml::from_str(&fs::read_to_string( root.join("apple/AppStore/metadata.toml"), )?)?) } fn verify(root: &Path) -> Result<(), Box> { let metadata = read_metadata(root)?; if metadata.app.bundle_identifier != BUNDLE_ID { return Err(format!("main bundle identifier must be {BUNDLE_ID}").into()); } if metadata.app.age_rating != "4+" || metadata.release.minimum_ios != "17.0" || metadata.release.minimum_watchos != "26.0" { return Err( "release metadata must retain the reviewed 4+ rating, iOS 17.0 minimum, and watchOS 26.0 minimum".into(), ); } if metadata.release.build.parse::()? == 0 { return Err("build number must be positive".into()); } for (name, value) in [ ("privacy URL", metadata.app.privacy_url.as_str()), ("support URL", metadata.app.support_url.as_str()), ("marketing URL", metadata.app.marketing_url.as_str()), ] { require_https(name, value)?; } for (name, value) in [ ("developer name", metadata.app.developer_name.as_str()), ("subtitle", metadata.app.subtitle.as_str()), ("description", metadata.app.description.as_str()), ("keywords", metadata.app.keywords.as_str()), ("category", metadata.app.category.as_str()), ("release notes", metadata.release.notes.as_str()), ("copyright", metadata.app.copyright.as_str()), ("review notes", metadata.app.review_notes.as_str()), ] { if value.trim().is_empty() { return Err(format!("{name} must not be empty").into()); } } if !metadata.release.notes.contains("Apple Watch") { return Err("release notes must describe the bundled Apple Watch companion".into()); } if metadata.content.advertising || metadata.content.account_creation || metadata.content.digital_purchases || metadata.content.tracking || metadata.content.user_generated_content { return Err("reviewed content declarations must remain false".into()); } let workspace: toml::Value = toml::from_str(&fs::read_to_string(root.join("Cargo.toml"))?)?; let workspace_version = workspace["workspace"]["package"]["version"] .as_str() .ok_or("workspace version is missing")?; if normalized_version(&metadata.release.version)? != normalized_version(workspace_version)? { return Err("App Store version must match the Rust workspace version".into()); } let project = fs::read_to_string(root.join("apple/project.yml"))?; for required in [ "PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.ironstorage\n", "PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.ironstorage.watch\n", "DEVELOPMENT_TEAM: MU22FMRGK8\n", "ITSAppUsesNonExemptEncryption: false\n", "NSFaceIDUsageDescription:", "NSCameraUsageDescription:", "deploymentTarget: \"26.0\"\n", "\"ARCHS[sdk=watchos*]\": \"arm64 arm64_32\"\n", "- target: IronStorageWatch\n embed: true", ] { if !project.contains(required) { return Err(format!("apple/project.yml is missing {required:?}").into()); } } if !project.contains(&format!( "MARKETING_VERSION: \"{}\"", metadata.release.version )) || !project.contains(&format!( "CURRENT_PROJECT_VERSION: \"{}\"", metadata.release.build )) { return Err("Xcode and App Store version/build metadata differ".into()); } let privacy = fs::read_to_string(root.join("apple/Resources/App/PrivacyInfo.xcprivacy"))?; for required in [ "NSPrivacyTracking\n\t", "NSPrivacyCollectedDataTypes\n\t", ] { if !privacy.contains(required) { return Err(format!("privacy manifest is missing {required:?}").into()); } } let icon = png_size(&root.join(&metadata.app.icon_file))?; if icon != (1024, 1024) { return Err(format!("app icon must be 1024x1024, found {}x{}", icon.0, icon.1).into()); } if metadata.screenshots.len() != 6 { return Err("exactly six reviewed iPhone screenshots are required".into()); } let iphone_size = ( metadata.screenshots[0].width, metadata.screenshots[0].height, ); if ![ (1242, 2688), (1284, 2778), (1260, 2736), (1290, 2796), (1320, 2868), ] .contains(&iphone_size) || metadata .screenshots .iter() .any(|screenshot| (screenshot.width, screenshot.height) != iphone_size) { return Err("iPhone screenshots must use one required 6.5-inch or 6.9-inch size".into()); } if metadata.watch_screenshots.len() != 2 { return Err("exactly the reviewed Watch list and detail screenshots are required".into()); } for screenshot in metadata .screenshots .iter() .chain(&metadata.watch_screenshots) { if screenshot.caption.trim().is_empty() { return Err(format!("{} requires a caption", screenshot.file).into()); } let actual = png_size(&root.join(&screenshot.file))?; if actual != (screenshot.width, screenshot.height) { return Err(format!("{} dimensions differ from metadata", screenshot.file).into()); } } for required in [ "LICENSE", "apple/AppStore/privacy/index.html", "apple/AppStore/DISTRIBUTION.md", "apple/AppStore/ExportOptions.plist", "apple/Resources/App/ThirdPartyLicenses.txt", ] { if !root.join(required).is_file() { return Err(format!("required release file is missing: {required}").into()); } } println!( "verified {} {} ({}) for {} with {} iPhone and {} Watch screenshots", metadata.app.name, metadata.release.version, metadata.release.build, metadata.app.bundle_identifier, metadata.screenshots.len(), metadata.watch_screenshots.len() ); Ok(()) } fn write_licenses(metadata_path: &Path, output: &Path) -> Result<(), Box> { let metadata: CargoMetadata = serde_json::from_slice(&fs::read(metadata_path)?)?; let packages: BTreeMap<_, _> = metadata .packages .into_iter() .map(|package| (package.id.clone(), package)) .collect(); let graph: BTreeMap<_, _> = metadata .resolve .nodes .into_iter() .map(|node| (node.id, node.dependencies)) .collect(); let root = packages .values() .find(|package| package.name == "ironstorage-apple") .ok_or("cargo metadata has no ironstorage-apple package")?; let mut pending = vec![root.id.clone()]; let mut reachable = BTreeSet::new(); while let Some(id) = pending.pop() { if reachable.insert(id.clone()) { pending.extend(graph.get(&id).into_iter().flatten().cloned()); } } let mut package_notices = String::new(); let mut license_texts: BTreeMap> = BTreeMap::new(); for package in packages .values() .filter(|package| reachable.contains(&package.id) && package.source.is_some()) { let directory = package .manifest_path .parent() .ok_or("dependency manifest has no parent directory")?; let mut license_paths = Vec::new(); if let Some(path) = &package.license_file { license_paths.push(directory.join(path)); } else { for entry in fs::read_dir(directory)? { let path = entry?.path(); let name = path .file_name() .and_then(|name| name.to_str()) .unwrap_or(""); if path.is_file() && ["LICENSE", "LICENCE", "COPYING", "NOTICE"] .iter() .any(|prefix| name.to_ascii_uppercase().starts_with(prefix)) { license_paths.push(path); } } } license_paths.sort(); license_paths.dedup(); package_notices.push_str(&format!( "\n{} {}\nLicense: {}\nSource: {}\n", package.name, package.version, package.license.as_deref().unwrap_or("see included license"), package.repository.as_deref().unwrap_or("see Cargo.lock") )); if license_paths.is_empty() { package_notices.push_str( "Notice: the published crate contains SPDX metadata but no separate license file.\n", ); } for path in license_paths { let text = fs::read_to_string(&path)? .lines() .map(str::trim_end) .collect::>() .join("\n"); license_texts.entry(text).or_default().push(format!( "{} {} ({})", package.name, package.version, path.file_name() .and_then(|name| name.to_str()) .unwrap_or("license") )); } } let mut notices = format!( "IronStorage third-party licenses\n================================\n\nGenerated from Cargo metadata for the iPhone Rust library. Source links also satisfy source-availability notice requirements for covered dependencies.\n\nPackages\n--------\n{package_notices}\nLicense texts\n-------------\n" ); for (text, used_by) in license_texts { notices.push_str(&format!( "\nUsed by: {}\n\n{}\n", used_by.join(", "), text.trim() )); } let mut file = OpenOptions::new() .write(true) .create(true) .truncate(true) .open(output)?; file.write_all(notices.as_bytes())?; Ok(()) } fn require_https(name: &str, value: &str) -> Result<(), Box> { let url = Url::parse(value)?; if url.scheme() != "https" || url.host_str().is_none() || url.username() != "" || url.password().is_some() { return Err(format!("{name} must be an HTTPS URL without credentials").into()); } Ok(()) } fn normalized_version(value: &str) -> Result, Box> { let mut parts = value .split('.') .map(str::parse) .collect::, _>>()?; while parts.last() == Some(&0) { parts.pop(); } Ok(parts) } fn png_size(path: &Path) -> Result<(u32, u32), Box> { let bytes = fs::read(path)?; if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" { return Err(format!("{} is not a PNG with an IHDR", path.display()).into()); } Ok(( u32::from_be_bytes(bytes[16..20].try_into()?), u32::from_be_bytes(bytes[20..24].try_into()?), )) } #[cfg(test)] mod tests { use super::*; fn metadata() -> Metadata { toml::from_str(include_str!("../../../apple/AppStore/metadata.toml")).unwrap() } #[test] fn metadata_keeps_the_watch_companion_in_release_scope() { let metadata = metadata(); assert_eq!(metadata.app.bundle_identifier, BUNDLE_ID); assert!(metadata.release.notes.contains("Apple Watch")); } #[test] fn app_store_and_rust_versions_ignore_trailing_zeroes() { assert_eq!(normalized_version("1.0").unwrap(), vec![1]); assert_eq!(normalized_version("1.0.0").unwrap(), vec![1]); } #[test] fn repository_release_metadata_is_consistent() { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() .parent() .unwrap(); verify(root).unwrap(); } }