feat: add validator to check for macOS app network entitlement (#2676)

Co-authored-by: Felix Angelov <felix@shorebird.dev>
This commit is contained in:
Bryan Oltman
2024-12-16 14:24:40 -05:00
committed by GitHub
parent 8eebf959e8
commit 9b87b3030f
5 changed files with 371 additions and 5 deletions
@@ -53,4 +53,8 @@ class Plist {
? releaseVersion
: '$releaseVersion+$buildNumber';
}
@override
String toString() =>
PropertyListSerialization.stringWithPropertyList(properties);
}
+8 -5
View File
@@ -26,7 +26,7 @@ class Doctor {
/// Validators that verify shorebird will work on macOS.
final List<Validator> macosCommandValidators = [
// TODO(bryanoltman): ensure app has network capabilities
MacosNetworkEntitlementValidator(),
];
/// Validators that should run on all commands.
@@ -34,6 +34,7 @@ class Doctor {
ShorebirdVersionValidator(),
ShorebirdFlutterValidator(),
AndroidInternetPermissionValidator(),
MacosNetworkEntitlementValidator(),
ShorebirdYamlAssetValidator(),
];
@@ -46,7 +47,7 @@ class Doctor {
final allIssues = <ValidationIssue>[];
final allFixableIssues = <ValidationIssue>[];
var numIssuesFixed = 0;
var totalIssuesFixed = 0;
for (final validator in validators) {
if (!validator.canRunInCurrentContext()) {
continue;
@@ -76,13 +77,15 @@ class Doctor {
// Re-run the validator to see if there are any remaining issues that
// we couldn't fix.
unresolvedIssues = await validator.validate();
if (unresolvedIssues.isEmpty) {
numIssuesFixed += issues.length - unresolvedIssues.length;
final numIssuesFixed = issues.length - unresolvedIssues.length;
if (numIssuesFixed > 0) {
totalIssuesFixed += numIssuesFixed;
final fixAppliedMessage =
'''($numIssuesFixed fix${numIssuesFixed == 1 ? '' : 'es'} applied)''';
validatorProgress.complete(
'''${validator.description} ${green.wrap(fixAppliedMessage)}''',
);
continue;
}
} else {
@@ -125,7 +128,7 @@ class Doctor {
allIssues.addAll(unresolvedIssues);
}
if (numIssuesFixed > 0) {
if (totalIssuesFixed > 0) {
return;
}
@@ -0,0 +1,92 @@
import 'dart:io';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/archive_analysis/plist.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
/// Checks that the macOS app has the network client entitlement. Without this
/// entitlement, the app will not be able to make network requests, and
/// Shorebird will not be able to check for patches.
class MacosNetworkEntitlementValidator extends Validator {
/// The plist key for the Outgoing Connections (Client) entitlement, which
/// allows macOS apps to make network requests.
static const networkClientEntitlementKey =
'com.apple.security.network.client';
Directory? get _macosDirectory {
final projectRoot = shorebirdEnv.getFlutterProjectRoot();
if (projectRoot == null) {
return null;
}
return Directory(p.join(projectRoot.path, 'macos'));
}
/// The entitlements plist file for the release build. This lives in
/// project_root/macos/Runner/Release.entitlements, where "Runner" may have
/// been renamed.
File? get _releaseEntitlementsPlist {
final entitlementParentCandidateDirectories =
_macosDirectory!.listSync().whereType<Directory>();
for (final appDir in entitlementParentCandidateDirectories) {
final entitlementsPlist = File(
p.join(appDir.path, 'Release.entitlements'),
);
if (entitlementsPlist.existsSync()) {
return entitlementsPlist;
}
}
return null;
}
@override
String get description => 'macOS app has Outgoing Connections entitlement';
@override
bool canRunInCurrentContext() =>
_macosDirectory != null && _macosDirectory!.existsSync();
@override
Future<List<ValidationIssue>> validate() async {
if (_releaseEntitlementsPlist == null) {
return [
const ValidationIssue(
severity: ValidationIssueSeverity.error,
message: 'Unable to find a Release.entitlements file',
),
];
}
if (!hasNetworkClientEntitlement(plistFile: _releaseEntitlementsPlist!)) {
return [
ValidationIssue(
severity: ValidationIssueSeverity.error,
message:
'''${_releaseEntitlementsPlist!.path} is missing the Outgoing Connections ($networkClientEntitlementKey) entitlement.''',
fix: () => addNetworkEntitlementToPlist(_releaseEntitlementsPlist!),
),
];
}
return [];
}
/// Whether the given entitlements plist file has the network client
/// entitlement.
@visibleForTesting
static bool hasNetworkClientEntitlement({required File plistFile}) =>
Plist(file: plistFile).properties[networkClientEntitlementKey] == true;
/// Adds the network client entitlement to the given entitlements plist file.
@visibleForTesting
static void addNetworkEntitlementToPlist(File entitlementsPlist) {
final plist = Plist(file: entitlementsPlist);
plist.properties[networkClientEntitlementKey] = true;
entitlementsPlist.writeAsStringSync(plist.toString());
}
}
@@ -6,6 +6,7 @@ import 'package:shorebird_cli/src/shorebird_process.dart';
export 'android_internet_permission_validator.dart';
export 'flavor_validator.dart';
export 'macos_network_entitlement_validator.dart';
export 'shorebird_flutter_validator.dart';
export 'shorebird_version_validator.dart';
export 'shorebird_yaml_asset_validator.dart';
@@ -0,0 +1,266 @@
import 'dart:io';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
import 'package:test/test.dart';
import '../mocks.dart';
void main() {
group(MacosNetworkEntitlementValidator, () {
const entitlementsPlistWithoutEntitlement = '''
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
</dict>
</plist>
''';
const entitlementsPlistWithEntitlement = '''
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
''';
late Directory projectRoot;
late ShorebirdEnv shorebirdEnv;
late MacosNetworkEntitlementValidator validator;
R runWithOverrides<R>(R Function() body) {
return runScoped(
body,
values: {
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
},
);
}
File releaseEntitlementsFile() => File(
p.join(projectRoot.path, 'macos', 'Runner', 'Release.entitlements'),
);
void setUpProjectRoot({String? entitlements}) {
Directory(
p.join(projectRoot.path, 'macos', 'Runner'),
).createSync(recursive: true);
if (entitlements != null) {
releaseEntitlementsFile()
..createSync(recursive: true)
..writeAsStringSync(entitlements);
}
}
setUp(() {
projectRoot = Directory.systemTemp.createTempSync();
shorebirdEnv = MockShorebirdEnv();
when(() => shorebirdEnv.getFlutterProjectRoot()).thenReturn(projectRoot);
validator = MacosNetworkEntitlementValidator();
});
group('description', () {
test('returns the correct description', () {
expect(
runWithOverrides(() => validator.description),
'macOS app has Outgoing Connections entitlement',
);
});
});
group('canRunInCurrentContext', () {
group('when macos directory exists', () {
setUp(setUpProjectRoot);
test('returns true', () {
expect(
runWithOverrides(() => validator.canRunInCurrentContext()),
isTrue,
);
});
});
group('when macos directory does not exist', () {
test('returns false', () {
expect(
runWithOverrides(() => validator.canRunInCurrentContext()),
isFalse,
);
});
});
});
group('validate', () {
group('when release entitlements plist does not exist', () {
setUp(setUpProjectRoot);
test('returns a validation issue with no fix', () async {
final issues = await runWithOverrides(() => validator.validate());
expect(issues, hasLength(1));
expect(
issues[0],
equals(
const ValidationIssue(
severity: ValidationIssueSeverity.error,
message: 'Unable to find a Release.entitlements file',
),
),
);
});
});
group('when release entitlements plist exists', () {
group('when network client entitlement is missing', () {
setUp(() {
setUpProjectRoot(entitlements: entitlementsPlistWithoutEntitlement);
});
test('returns a validation issue with a fix', () async {
final validationResults = await runWithOverrides(
() => validator.validate(),
);
expect(validationResults, hasLength(1));
final issue = validationResults[0];
expect(issue.severity, ValidationIssueSeverity.error);
expect(
issue.message,
contains(
'''is missing the Outgoing Connections (com.apple.security.network.client) entitlement.''',
),
);
expect(issue.fix, isNotNull);
expect(
MacosNetworkEntitlementValidator.hasNetworkClientEntitlement(
plistFile: releaseEntitlementsFile(),
),
isFalse,
);
runWithOverrides(() => issue.fix!());
expect(
MacosNetworkEntitlementValidator.hasNetworkClientEntitlement(
plistFile: releaseEntitlementsFile(),
),
isTrue,
);
});
});
group('when network client entitlement is present', () {
setUp(() {
setUpProjectRoot(entitlements: entitlementsPlistWithEntitlement);
});
test('returns an empty list', () async {
expect(await runWithOverrides(() => validator.validate()), isEmpty);
});
});
});
});
group('fix', () {
group('when the network client entitlement is missing', () {
setUp(() {
setUpProjectRoot(entitlements: entitlementsPlistWithoutEntitlement);
});
test('adds the network client entitlement to the entitlements plist',
() {
expect(
MacosNetworkEntitlementValidator.hasNetworkClientEntitlement(
plistFile: releaseEntitlementsFile(),
),
isFalse,
);
MacosNetworkEntitlementValidator.addNetworkEntitlementToPlist(
releaseEntitlementsFile(),
);
expect(
MacosNetworkEntitlementValidator.hasNetworkClientEntitlement(
plistFile: releaseEntitlementsFile(),
),
isTrue,
);
});
});
group('when the network client entitlement is present', () {
setUp(() {
setUpProjectRoot(entitlements: entitlementsPlistWithEntitlement);
});
test('does not modify the entitlements plist', () {
final plistContents = releaseEntitlementsFile().readAsStringSync();
expect(
MacosNetworkEntitlementValidator.hasNetworkClientEntitlement(
plistFile: releaseEntitlementsFile(),
),
isTrue,
);
MacosNetworkEntitlementValidator.addNetworkEntitlementToPlist(
releaseEntitlementsFile(),
);
expect(
MacosNetworkEntitlementValidator.hasNetworkClientEntitlement(
plistFile: releaseEntitlementsFile(),
),
isTrue,
);
final updatedPlistContents =
releaseEntitlementsFile().readAsStringSync();
expect(updatedPlistContents, plistContents);
});
});
});
group('plistHasNetworkClientEntitlement', () {
group('when entitlement is present', () {
setUp(() {
setUpProjectRoot(entitlements: entitlementsPlistWithEntitlement);
});
test('returns true', () {
expect(
MacosNetworkEntitlementValidator.hasNetworkClientEntitlement(
plistFile: releaseEntitlementsFile(),
),
isTrue,
);
});
});
group('when entitlement is not present', () {
setUp(() {
setUpProjectRoot(entitlements: entitlementsPlistWithoutEntitlement);
});
test('returns false', () {
expect(
MacosNetworkEntitlementValidator.hasNetworkClientEntitlement(
plistFile: releaseEntitlementsFile(),
),
isFalse,
);
});
});
});
});
}