From e87608a2e81cd0b1a141e2872689cabcf8856ea9 Mon Sep 17 00:00:00 2001 From: saimskywalker <15120878+saimskywalker@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:36:39 +0800 Subject: [PATCH] feat: required or conditional level per plugin purpose string Each built-in plugin key in purpose-string-missing is now required or conditional, with a link to the README section it comes from. A missing conditional key is a warning that states the condition; image_picker's microphone key is the first one. --- CHANGELOG.md | 10 ++ README.md | 11 +- doc/configuration.md | 2 +- doc/rules.md | 45 +++++- lib/src/data/plugin_requirements.dart | 189 +++++++++++++++++++----- lib/src/rules/purpose_strings.dart | 63 ++++++-- test/cli_test.dart | 28 ++++ test/fixtures/README.md | 2 +- test/fixtures/rejected_app/pubspec.lock | 7 + test/fixtures/rejected_app/pubspec.yaml | 1 + test/rules/purpose_strings_test.dart | 153 +++++++++++++++++++ 11 files changed, 456 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 301d628..32e907f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ - The production-shaped fixture pins five values with `checks:`. - `--list-rules` also lists the project checks from the configuration file, with their severity and reason. +- `purpose-string-missing` gives each built-in plugin key a level, with a + link to the plugin README section it comes from (table in + `doc/rules.md`). A missing required key is still an error; a missing + conditional key is a warning that states the condition. image_picker's + `NSMicrophoneUsageDescription` is conditional ("only if you record video"), + so an image_picker app without it now gets a warning instead of an error. + `purpose-string-quality` accepts a "not used" string for a conditional key. +- The `rejected_app` fixture links `local_auth` without + `NSFaceIDUsageDescription`, so it misses one required and one conditional + key. ## 0.2.0 diff --git a/README.md b/README.md index b074729..50125aa 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,9 @@ privacy-manifest-present — The app target ships a PrivacyInfo.xcprivacy purpose-string-missing — Linked plugins have the purpose strings they need error ios/Runner/Info.plist `NSPhotoLibraryUsageDescription` is missing, but image_picker needs it. - error ios/Runner/Info.plist - `NSMicrophoneUsageDescription` is missing, but image_picker needs it. + warning ios/Runner/Info.plist + `NSMicrophoneUsageDescription` is missing; image_picker needs it only if you + record video (image_picker README, "iOS"). purpose-string-quality — Purpose strings name a feature and give an example, not restate the permission error ios/Runner/Info.plist:5 @@ -78,10 +79,12 @@ purpose-string-quality — Purpose strings name a feature and give an example, n that uses it and give an example. Current text: "This app needs camera access." -✗ 15 rules checked: 3 errors, 1 warning, 0 info. Failing: findings at or above `error`. +✗ 15 rules checked: 2 errors, 2 warnings, 0 info. Failing: findings at or above `error`. ``` -The exit code is `1`, so CI fails. Without `--brief` each finding also says +A key a plugin needs in every app is an error; a key its README asks for +only when you use one feature, like the microphone for recording video, is a +warning that names the feature. The exit code is `1`, so CI fails. Without `--brief` each finding also says why it matters, how to fix it, and links Apple's or Google's documentation. `--format json` and `--sarif` give the same findings in machine-readable form. diff --git a/doc/configuration.md b/doc/configuration.md index b4b5c46..f3ea943 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -96,7 +96,7 @@ code 2) with the line number, so a typo cannot silently switch a check off. |---|---| | `privacy-manifest-values` | `extra_data_types`, `extra_purposes`, `extra_api_reasons` (category → reasons) | | `privacy-manifest-sdk-data` | `sdk_data_types` (package → data types; `[]` disables a built-in entry) | -| `purpose-string-missing` | `required_by_plugin` (package → Info.plist keys it requests), `linked_by_plugin` (package → Info.plist keys its binary only references) | +| `purpose-string-missing` | `required_by_plugin` (package → Info.plist keys it requests, all required; replaces a built-in row), `linked_by_plugin` (package → Info.plist keys its binary only references) | | `purpose-string-quality` | `min_words`, `require_example`, `app_names`, `extra_example_markers`, `extra_unused_markers`, `linked_only` (Info.plist key → why it is there) | | `background-modes-justified` | `allow` (mode → justification), `extra_evidence` (mode → packages) | | `android-picker-queries` | `plugins` (extra file-picker packages) | diff --git a/doc/rules.md b/doc/rules.md index 91e7dfc..f03b0bb 100644 --- a/doc/rules.md +++ b/doc/rules.md @@ -36,7 +36,7 @@ Planned rules link the issue that tracks them. | `privacy-manifest-tracking` | `NSPrivacyTracking` agrees with each entry's Tracking flag and with `NSPrivacyTrackingDomains` | A manifest that contradicts itself and the privacy label | | `privacy-manifest-sdk-data` | Collected data covers what linked SDKs (Firebase, AdMob, StoreKit, Sentry, …) collect, read from `pubspec.lock` | "Collects nothing" next to an analytics SDK, a label/manifest mismatch reviewers flag | | `tracking-usage-description` | `NSUserTrackingUsageDescription` exists exactly when `PrivacyInfo.xcprivacy` declares tracking; warns when `app_tracking_transparency` is linked without it | App Store Connect refusing "Add for Review"; an ATT request that terminates the app | -| `purpose-string-missing` | Plugins that touch the camera, microphone, photos, location, contacts, Face ID… have their `NS…UsageDescription`; Always location also has When-In-Use; protected APIs a bundled SDK only references (`linked_by_plugin`) have their key too, both location keys for a CoreLocation reference | ITMS-90683 "Missing purpose string", also for an API the app never calls; a crash at the first permission request | +| `purpose-string-missing` | Plugins that touch the camera, microphone, photos, location, contacts, Face ID… have their `NS…UsageDescription` (an error for a required key, a warning that states the condition for a conditional one; see [Plugin purpose strings](#plugin-purpose-strings)); Always location also has When-In-Use; protected APIs a bundled SDK only references (`linked_by_plugin`) have their key too, both location keys for a CoreLocation reference | ITMS-90683 "Missing purpose string", also for an API the app never calls; a crash at the first permission request | | `purpose-string-quality` | Purpose strings name a feature and give an example instead of restating the permission; a string for a key the app does not use says so and why the key is there (recognised by its wording or listed under `linked_only`), and is an error while a plugin that requests the permission is a direct dependency | Guideline 5.1.1(ii) rejection ("needs camera access"); a "not used" string users see in a real permission prompt | | `background-modes-justified` | Every `UIBackgroundModes` entry is a mode Apple defines and has a justification recorded under `allow`: an error when no linked plugin uses the mode, a warning when one plausibly does; `processing` also needs `BGTaskSchedulerPermittedIdentifiers` | Guideline 2.5.4 rejection ("unable to locate any features that require persistent audio") | | `export-options-build-number` | Every `ExportOptions*.plist` with method `app-store` / `app-store-connect` sets `manageAppVersionAndBuildNumber` to false (it defaults to YES) | Xcode silently bumping `CFBundleVersion` at export | @@ -47,6 +47,39 @@ Planned rules link the issue that tracks them. | `android-16kb-page-size` | When `targetSdk` is 35+ or cannot be determined: Android Gradle Plugin ≥ 8.5.1 (or legacy packaging), NDK r28+ for your own native code | Play not letting you release an update without 16 KB page-size support, from 1 February 2027 ([Android guide](https://developer.android.com/guide/practices/page-sizes#google-play-compatibility-requirement)) | | `unreadable-file` | Every file above parses (binary plists get a `plutil` hint) | Rules silently passing a file nobody could read | +## Plugin purpose strings + +`purpose-string-missing` knows the Info.plist keys these plugins ask for. Each +key is **required** (every app that links the plugin needs it; missing is an +error) or **conditional** (the plugin's README asks for it only when you use +one feature; missing is a warning that names the feature). A key is +conditional only when the plugin's own README says so. Keys you add with +`required_by_plugin` are required. + +| Plugin | Required | Conditional | Source | +|---|---|---|---| +| `image_picker` | `NSPhotoLibraryUsageDescription`, `NSCameraUsageDescription` | `NSMicrophoneUsageDescription`: only if you record video | [README, "iOS"](https://pub.dev/packages/image_picker#ios) | +| `camera` | `NSCameraUsageDescription`, `NSMicrophoneUsageDescription` | | [README, "iOS"](https://pub.dev/packages/camera#ios) | +| `mobile_scanner` | `NSCameraUsageDescription` | | [README, "iOS"](https://pub.dev/packages/mobile_scanner#ios) | +| `geolocator` | `NSLocationWhenInUseUsageDescription` | | [README, "Usage" → "iOS"](https://pub.dev/packages/geolocator#usage) | +| `location` | `NSLocationWhenInUseUsageDescription` | | [README, "iOS"](https://pub.dev/packages/location#ios) | +| `record` | `NSMicrophoneUsageDescription` | | [README, "iOS"](https://pub.dev/packages/record#ios) | +| `flutter_sound` | `NSMicrophoneUsageDescription` | | [Installation guide, "Post Installation"](https://flutter-sound.canardoux.xyz/tau/guides/guides-install.html#post-installation) | +| `speech_to_text` | `NSSpeechRecognitionUsageDescription`, `NSMicrophoneUsageDescription` | | [README, "iOS & macOS"](https://pub.dev/packages/speech_to_text#ios--macos) | +| `local_auth` | `NSFaceIDUsageDescription` | | [local_auth_darwin README, "Setup"](https://pub.dev/packages/local_auth_darwin#setup) | +| `flutter_contacts` | `NSContactsUsageDescription` | | [README, "Configure Permissions"](https://pub.dev/packages/flutter_contacts#2-configure-permissions) | +| `flutter_blue_plus` | `NSBluetoothAlwaysUsageDescription` | | [README, "Add permissions for iOS"](https://pub.dev/packages/flutter_blue_plus#add-permissions-for-ios) | +| `photo_manager` | `NSPhotoLibraryUsageDescription` | | [README, "iOS config preparation"](https://pub.dev/packages/photo_manager#ios-config-preparation) | +| `gal` | `NSPhotoLibraryAddUsageDescription` | | [README, "iOS Setup"](https://pub.dev/packages/gal#ios-setup) | +| `flutter_webrtc` | `NSCameraUsageDescription`, `NSMicrophoneUsageDescription` | | [README, "iOS"](https://pub.dev/packages/flutter_webrtc#ios) | +| `livekit_client` | `NSCameraUsageDescription`, `NSMicrophoneUsageDescription` | | [README, "iOS"](https://pub.dev/packages/livekit_client#ios) | +| `agora_rtc_engine` | `NSMicrophoneUsageDescription`, `NSCameraUsageDescription` | | [README, "iOS & macOS"](https://pub.dev/packages/agora_rtc_engine#ios--macos) | + +When another linked plugin requires the same key, or a bundled SDK references +its API (`linked_by_plugin`), the missing key is an error. A purpose string +that says the app does not use a conditional key's resource is not reported +by `purpose-string-quality`, since the app may not use that feature. + ## What a finding looks like Without `--brief`, every rule also says @@ -65,14 +98,18 @@ purpose-string-missing — Linked plugins have the purpose strings they need reaches the permission prompt. The upload check is static: a protected API that a bundled native SDK only references (linked, never requested) needs its key too, and a CoreLocation reference usually includes the Always - authorization API, so both location keys are asked for. + authorization API, so both location keys are asked for. A key a plugin's + README asks for only when you use one of its features (image_picker's + microphone key, for recording video) is a warning that names the feature. fix: Add the key to ios/Runner/Info.plist with a sentence that names the feature and gives an example (see `purpose-string-quality`). When a dependency only references the API, add the key anyway with a sentence saying the app does not use the resource and why the key is present (see `purpose-string-quality` -> `linked_only`); deleting it only fails the next - upload. Extend the plugin table with the `required_by_plugin` option, and - record SDKs that only reference an API with `linked_by_plugin`. + upload. For a warning about a key needed only for one feature, add the key if + the app uses that feature, or `ignore` the finding with the reason. Extend the + plugin table with the `required_by_plugin` option, and record SDKs that only + reference an API with `linked_by_plugin`. docs: https://developer.apple.com/documentation/uikit/requesting-access-to-protected-resources ``` diff --git a/lib/src/data/plugin_requirements.dart b/lib/src/data/plugin_requirements.dart index 5591a02..5dae58e 100644 --- a/lib/src/data/plugin_requirements.dart +++ b/lib/src/data/plugin_requirements.dart @@ -36,43 +36,160 @@ const backgroundModeEvidence = >{ 'bluetooth-peripheral': ['flutter_ble_peripheral'], }; -/// Info.plist purpose-string keys each plugin's README says it needs. +/// Whether a plugin needs a purpose string in every app that links it. +enum KeyLevel { + /// Every app that links the plugin needs the key. + required, + + /// Only apps that use one feature of the plugin need the key; the plugin's + /// README names the feature. + conditional, +} + +/// One Info.plist purpose-string key a plugin's README asks for. +class PluginKey { + /// A key every app that links the plugin needs. + const PluginKey.required(this.key) : condition = null; + + /// A key needed only when [condition] holds. + const PluginKey.conditional(this.key, String this.condition); + + /// The Info.plist key. + final String key; + + /// When the key is needed, completing "needed only if …" (for example + /// `you record video`); `null` for a required key. + final String? condition; + + /// [KeyLevel.conditional] when the README gives a condition. + KeyLevel get level => + condition == null ? KeyLevel.required : KeyLevel.conditional; +} + +/// The purpose strings one plugin asks for, and where its README says so. +class PluginPurposeStrings { + /// Creates a table row. + const PluginPurposeStrings({ + required this.source, + required this.section, + required this.keys, + }); + + /// Link to the README (or setup guide) section that lists the keys. + final String source; + + /// Where [source] points, as a reader finds it: `image_picker README, + /// "iOS"`. + final String section; + + /// The keys, each with its level. + final List keys; +} + +const _camera = PluginKey.required('NSCameraUsageDescription'); +const _microphone = PluginKey.required('NSMicrophoneUsageDescription'); +const _photoLibrary = PluginKey.required('NSPhotoLibraryUsageDescription'); +const _whenInUse = PluginKey.required('NSLocationWhenInUseUsageDescription'); + +/// Info.plist purpose-string keys each plugin's README asks for, with the +/// level the README gives each one. /// -/// A missing key means the build is refused at upload with ITMS-90683, or the -/// app is terminated by iOS the first time the API is called. -const purposeStringsByPlugin = >{ - 'image_picker': [ - 'NSPhotoLibraryUsageDescription', - 'NSCameraUsageDescription', - 'NSMicrophoneUsageDescription', - ], - 'camera': ['NSCameraUsageDescription', 'NSMicrophoneUsageDescription'], - 'mobile_scanner': ['NSCameraUsageDescription'], - 'geolocator': ['NSLocationWhenInUseUsageDescription'], - 'location': ['NSLocationWhenInUseUsageDescription'], - 'record': ['NSMicrophoneUsageDescription'], - 'flutter_sound': ['NSMicrophoneUsageDescription'], - 'speech_to_text': [ - 'NSSpeechRecognitionUsageDescription', - 'NSMicrophoneUsageDescription', - ], - 'local_auth': ['NSFaceIDUsageDescription'], - 'flutter_contacts': ['NSContactsUsageDescription'], - 'flutter_blue_plus': ['NSBluetoothAlwaysUsageDescription'], - 'photo_manager': ['NSPhotoLibraryUsageDescription'], - 'gal': ['NSPhotoLibraryAddUsageDescription'], - 'flutter_webrtc': [ - 'NSCameraUsageDescription', - 'NSMicrophoneUsageDescription', - ], - 'livekit_client': [ - 'NSCameraUsageDescription', - 'NSMicrophoneUsageDescription', - ], - 'agora_rtc_engine': [ - 'NSCameraUsageDescription', - 'NSMicrophoneUsageDescription', - ], +/// A missing required key means the build is refused at upload with +/// ITMS-90683, or the app is terminated by iOS the first time the API is +/// called. A key is conditional only when the plugin's own README says it is +/// needed for one feature; the condition is quoted from there. +const purposeStringsByPlugin = { + 'image_picker': PluginPurposeStrings( + source: 'https://pub.dev/packages/image_picker#ios', + section: 'image_picker README, "iOS"', + keys: [ + _photoLibrary, + _camera, + // "describe why your app needs access to the microphone, if you + // intend to record videos." + PluginKey.conditional('NSMicrophoneUsageDescription', 'you record video'), + ], + ), + 'camera': PluginPurposeStrings( + source: 'https://pub.dev/packages/camera#ios', + section: 'camera README, "iOS"', + keys: [_camera, _microphone], + ), + 'mobile_scanner': PluginPurposeStrings( + source: 'https://pub.dev/packages/mobile_scanner#ios', + section: 'mobile_scanner README, "iOS"', + keys: [_camera], + ), + 'geolocator': PluginPurposeStrings( + source: 'https://pub.dev/packages/geolocator#usage', + section: 'geolocator README, "Usage" -> "iOS"', + keys: [_whenInUse], + ), + 'location': PluginPurposeStrings( + source: 'https://pub.dev/packages/location#ios', + section: 'location README, "iOS"', + keys: [_whenInUse], + ), + 'record': PluginPurposeStrings( + source: 'https://pub.dev/packages/record#ios', + section: 'record README, "iOS"', + keys: [_microphone], + ), + 'flutter_sound': PluginPurposeStrings( + source: + 'https://flutter-sound.canardoux.xyz/tau/guides/guides-install.html#post-installation', + section: 'Flutter Sound installation guide, "Post Installation"', + keys: [_microphone], + ), + 'speech_to_text': PluginPurposeStrings( + source: 'https://pub.dev/packages/speech_to_text#ios--macos', + section: 'speech_to_text README, "iOS & macOS"', + keys: [ + PluginKey.required('NSSpeechRecognitionUsageDescription'), + _microphone, + ], + ), + 'local_auth': PluginPurposeStrings( + source: 'https://pub.dev/packages/local_auth_darwin#setup', + section: 'local_auth_darwin README, "Setup"', + keys: [PluginKey.required('NSFaceIDUsageDescription')], + ), + 'flutter_contacts': PluginPurposeStrings( + source: 'https://pub.dev/packages/flutter_contacts#2-configure-permissions', + section: 'flutter_contacts README, "Configure Permissions"', + keys: [PluginKey.required('NSContactsUsageDescription')], + ), + 'flutter_blue_plus': PluginPurposeStrings( + source: + 'https://pub.dev/packages/flutter_blue_plus#add-permissions-for-ios', + section: 'flutter_blue_plus README, "Add permissions for iOS"', + keys: [PluginKey.required('NSBluetoothAlwaysUsageDescription')], + ), + 'photo_manager': PluginPurposeStrings( + source: 'https://pub.dev/packages/photo_manager#ios-config-preparation', + section: 'photo_manager README, "iOS config preparation"', + keys: [_photoLibrary], + ), + 'gal': PluginPurposeStrings( + source: 'https://pub.dev/packages/gal#ios-setup', + section: 'gal README, "iOS Setup"', + keys: [PluginKey.required('NSPhotoLibraryAddUsageDescription')], + ), + 'flutter_webrtc': PluginPurposeStrings( + source: 'https://pub.dev/packages/flutter_webrtc#ios', + section: 'flutter_webrtc README, "iOS"', + keys: [_camera, _microphone], + ), + 'livekit_client': PluginPurposeStrings( + source: 'https://pub.dev/packages/livekit_client#ios', + section: 'livekit_client README, "iOS"', + keys: [_camera, _microphone], + ), + 'agora_rtc_engine': PluginPurposeStrings( + source: 'https://pub.dev/packages/agora_rtc_engine#ios--macos', + section: 'agora_rtc_engine README, "iOS & macOS"', + keys: [_microphone, _camera], + ), }; /// Words that describe the *permission itself* for each purpose-string key. diff --git a/lib/src/rules/purpose_strings.dart b/lib/src/rules/purpose_strings.dart index cf51944..623b378 100644 --- a/lib/src/rules/purpose_strings.dart +++ b/lib/src/rules/purpose_strings.dart @@ -45,7 +45,9 @@ class PurposeStringMissingRule extends Rule { 'check is static: a protected API that a bundled native SDK only ' 'references (linked, never requested) needs its key too, and a ' 'CoreLocation reference usually includes the Always authorization API, ' - 'so both location keys are asked for.'; + 'so both location keys are asked for. A key a plugin\'s README asks ' + 'for only when you use one of its features (image_picker\'s microphone ' + 'key, for recording video) is a warning that names the feature.'; @override String get fix => @@ -54,7 +56,9 @@ class PurposeStringMissingRule extends Rule { 'dependency only references the API, add the key anyway with a ' 'sentence saying the app does not use the resource and why the key is ' 'present (see `purpose-string-quality` -> `linked_only`); deleting it ' - 'only fails the next upload. Extend the plugin table with the ' + 'only fails the next upload. For a warning about a key needed only for ' + 'one feature, add the key if the app uses that feature, or `ignore` ' + 'the finding with the reason. Extend the plugin table with the ' '`required_by_plugin` option, and record SDKs that only reference an ' 'API with `linked_by_plugin`.'; @@ -96,15 +100,34 @@ class PurposeStringMissingRule extends Rule { final project = context.project; final plist = project.infoPlist; if (plist == null) return; - final table = >{ + final table = { ...purposeStringsByPlugin, - ..._option(context, 'required_by_plugin'), + for (final MapEntry(key: plugin, value: keys) in _option( + context, + 'required_by_plugin', + ).entries) + plugin: PluginPurposeStrings( + source: '', + section: '', + keys: [for (final key in keys) PluginKey.required(key)], + ), }; + // Key to the linked plugins that need it in every app, and to the + // linked plugins that need it only for one feature. final needs = >{}; - for (final MapEntry(key: plugin, value: keys) in table.entries) { + final conditional = >{}; + for (final MapEntry(key: plugin, value: row) in table.entries) { if (!project.links(plugin)) continue; - for (final key in keys) { - needs.putIfAbsent(key, () => []).add(plugin); + for (final entry in row.keys) { + if (entry.level == KeyLevel.required) { + needs.putIfAbsent(entry.key, () => []).add(plugin); + } else { + conditional.putIfAbsent(entry.key, () => []).add(( + plugin, + entry, + row.section, + )); + } } } @@ -142,6 +165,22 @@ class PurposeStringMissingRule extends Rule { ); } } + for (final MapEntry(key: key, value: plugins) in conditional.entries) { + if (needs.containsKey(key) || + references.containsKey(key) || + plist.root.has(key)) { + continue; + } + final reasons = [ + for (final (plugin, entry, section) in plugins) + '$plugin needs it only if ${entry.condition} ($section)', + ]; + yield finding( + '`$key` is missing; ${reasons.join('; ')}.', + path: plist.path, + severity: Severity.warning, + ); + } for (final MapEntry(key: key, value: plugins) in references.entries) { if (needs.containsKey(key) || plist.root.has(key)) continue; final location = key == _whenInUse || key == _always @@ -406,10 +445,16 @@ class PurposeStringQualityRule extends Rule { // as one: says the app does not use the resource, says why the key // exists, and gives no example of a use. if (declared || (saysUnused && givesReason && !givesExample)) { + // A conditional key is honest to mark unused: the app may not use + // the feature that needs it. final requesting = [ - for (final MapEntry(key: plugin, value: keys) + for (final MapEntry(key: plugin, value: row) in purposeStringsByPlugin.entries) - if (keys.contains(key) && context.project.links(plugin)) plugin, + if (row.keys.any( + (k) => k.key == key && k.level == KeyLevel.required, + ) && + context.project.links(plugin)) + plugin, ]; if (requesting.isNotEmpty) { final direct = requesting.any(context.project.linksDirectly); diff --git a/test/cli_test.dart b/test/cli_test.dart index 6ea7c1b..be9dafa 100644 --- a/test/cli_test.dart +++ b/test/cli_test.dart @@ -73,6 +73,34 @@ void main() { expect(out, isNot(contains('why: '))); }); + test('a required key is an error and a conditional key a warning', () { + final out = run([rejected, '--brief']).out; + expect( + out, + contains( + ' error $rejected/ios/Runner/Info.plist\n' + ' `NSFaceIDUsageDescription` is missing, but local_auth needs it.', + ), + ); + expect( + out, + contains( + ' warning $rejected/ios/Runner/Info.plist\n' + ' `NSMicrophoneUsageDescription` is missing; image_picker needs it', + ), + ); + final json = jsonDecode(run([rejected, '--format', 'json']).out) as Map; + final missing = [ + for (final f in (json['findings'] as List).cast>()) + if (f['rule'] == 'purpose-string-missing') + (f['severity'], (f['message'] as String).split('`')[1]), + ]; + expect(missing, [ + ('error', 'NSFaceIDUsageDescription'), + ('warning', 'NSMicrophoneUsageDescription'), + ]); + }); + test('--fail-on never exits 0 even with errors', () { expect(run([rejected, '--fail-on', 'never']).code, ExitCodes.ok); }); diff --git a/test/fixtures/README.md b/test/fixtures/README.md index ceaeebd..f23b728 100644 --- a/test/fixtures/README.md +++ b/test/fixtures/README.md @@ -8,7 +8,7 @@ placeholder values. | Fixture | Shape | Expected result | |---|---|---| | `clean_app` | Minimal, like `flutter create` output | No findings | -| `rejected_app` | Minimal, with one mistake per rule | Findings from most rules (see `test/cli_test.dart`) | +| `rejected_app` | Minimal, with one mistake per rule (`purpose-string-missing` gets one required and one conditional key) | Findings from most rules (see `test/cli_test.dart`) | | `production_app` | A multi-flavor app shaped like one in production (below) | No findings with `--fail-on info`; output in `production_app.golden.txt` | ## production_app diff --git a/test/fixtures/rejected_app/pubspec.lock b/test/fixtures/rejected_app/pubspec.lock index 2d78436..1d1bde1 100644 --- a/test/fixtures/rejected_app/pubspec.lock +++ b/test/fixtures/rejected_app/pubspec.lock @@ -20,6 +20,13 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + url: "https://pub.dev" + source: hosted + version: "2.3.0" test: dependency: "direct dev" description: diff --git a/test/fixtures/rejected_app/pubspec.yaml b/test/fixtures/rejected_app/pubspec.yaml index 38fc490..3363d1c 100644 --- a/test/fixtures/rejected_app/pubspec.yaml +++ b/test/fixtures/rejected_app/pubspec.yaml @@ -10,3 +10,4 @@ dependencies: file_picker: ^10.0.0 firebase_analytics: ^12.0.0 image_picker: ^1.0.0 + local_auth: ^2.3.0 diff --git a/test/rules/purpose_strings_test.dart b/test/rules/purpose_strings_test.dart index e23ac15..b18b34d 100644 --- a/test/rules/purpose_strings_test.dart +++ b/test/rules/purpose_strings_test.dart @@ -1,4 +1,5 @@ import 'package:storehound/src/data/linked_api_references.dart'; +import 'package:storehound/src/data/plugin_requirements.dart'; import 'package:storehound/src/rules/purpose_strings.dart'; import 'package:storehound/storehound.dart'; import 'package:test/test.dart'; @@ -81,6 +82,132 @@ void main() { }); }); + group('purpose-string-missing, required and conditional keys', () { + const rule = PurposeStringMissingRule(); + const microphone = 'NSMicrophoneUsageDescription'; + + test('a missing required key is an error', () { + expect( + runRule(rule, { + info: infoPlist(key('NSPhotoLibraryUsageDescription', 'x')), + 'pubspec.lock': lockOf(['image_picker']), + }).where((f) => f.severity == Severity.error), + [ + isFinding( + severity: Severity.error, + message: + '`NSCameraUsageDescription` is missing, but ' + 'image_picker needs it.', + ), + ], + ); + }); + + test('a missing conditional key is a warning that states the ' + 'condition', () { + expect( + runRule(rule, { + info: infoPlist( + key('NSPhotoLibraryUsageDescription', 'x') + + key('NSCameraUsageDescription', 'x'), + ), + 'pubspec.lock': lockOf(['image_picker']), + }), + [ + isFinding( + severity: Severity.warning, + message: + '`$microphone` is missing; image_picker needs it only ' + 'if you record video (image_picker README, "iOS").', + ), + ], + ); + }); + + test('a present conditional key passes', () { + expect( + runRule(rule, { + info: infoPlist( + key('NSPhotoLibraryUsageDescription', 'x') + + key('NSCameraUsageDescription', 'x') + + key(microphone, 'x'), + ), + 'pubspec.lock': lockOf(['image_picker']), + }), + isEmpty, + ); + }); + + test('a plugin that requires the key makes it an error', () { + expect( + runRule(rule, { + info: infoPlist( + key('NSPhotoLibraryUsageDescription', 'x') + + key('NSCameraUsageDescription', 'x'), + ), + 'pubspec.lock': lockOf(['image_picker', 'camera']), + }), + [ + isFinding( + severity: Severity.error, + message: '`$microphone` is missing, but camera needs it.', + ), + ], + ); + }); + + test('a linked-only reference to the key makes it an error', () { + expect( + runRule( + rule, + { + info: infoPlist( + key('NSPhotoLibraryUsageDescription', 'x') + + key('NSCameraUsageDescription', 'x'), + ), + 'pubspec.lock': lockOf(['image_picker', 'acme_calls']), + }, + options: { + 'linked_by_plugin': { + 'acme_calls': [microphone], + }, + }, + ), + [ + isFinding( + severity: Severity.error, + message: contains('acme_calls references'), + ), + ], + ); + }); + + test('every built-in key has a level, and every row a source', () { + expect(purposeStringsByPlugin, hasLength(16)); + for (final MapEntry(key: plugin, value: row) + in purposeStringsByPlugin.entries) { + expect(row.source, startsWith('https://'), reason: plugin); + expect(row.section, isNotEmpty, reason: plugin); + expect(row.keys, isNotEmpty, reason: plugin); + for (final k in row.keys) { + expect(k.key, endsWith('UsageDescription'), reason: plugin); + if (k.level == KeyLevel.conditional) { + expect(k.condition, isNotEmpty, reason: '$plugin ${k.key}'); + } + } + } + expect( + [ + for (final MapEntry(key: plugin, value: row) + in purposeStringsByPlugin.entries) + for (final k in row.keys) + if (k.level == KeyLevel.conditional) '$plugin ${k.key}', + ], + ['image_picker $microphone'], + ); + }); + }); + group('purpose-string-missing, linked-only references', () { const rule = PurposeStringMissingRule(); const linkedCalls = { @@ -501,6 +628,32 @@ void main() { ); }); + test('passes a "not used" string for a conditional key', () { + const notUsedMicrophone = + 'This app does not use your microphone. iOS requires this text ' + 'because the image picker plugin included in the app can record ' + 'video; the app never records video.'; + final files = { + info: infoPlist( + key('NSMicrophoneUsageDescription', notUsedMicrophone), + ), + }; + expect( + runRule(rule, { + ...files, + 'pubspec.lock': lockOf(['image_picker']), + }), + isEmpty, + ); + expect( + runRule(rule, { + ...files, + 'pubspec.lock': lockOf(['camera']), + }), + [isFinding(message: contains('camera is linked and requests'))], + ); + }); + test('only warns when the requesting plugin is transitive', () { expect( runRule(rule, {