feat: adding public key to the patch command (#2149)

Co-authored-by: Bryan Oltman <bryan@shorebird.dev>
This commit is contained in:
Erick
2024-05-24 18:02:48 -03:00
committed by GitHub
parent c69212e1a1
commit db903834bd
16 changed files with 300 additions and 47 deletions
@@ -11,7 +11,6 @@ import 'package:shorebird_cli/src/commands/patch/patcher.dart';
import 'package:shorebird_cli/src/common_arguments.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/extensions/file.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart';
import 'package:shorebird_cli/src/platform.dart';
@@ -35,11 +34,6 @@ class AndroidPatcher extends Patcher {
required super.target,
});
@override
Future<void> assertArgsAreValid() async {
argResults.file(CommonArguments.privateKeyArgName)?.assertExists();
}
@override
ReleaseType get releaseType => ReleaseType.android;
@@ -74,6 +68,7 @@ class AndroidPatcher extends Patcher {
flavor: flavor,
target: target,
args: argResults.forwardedArgs,
base64PublicKey: argResults.encodedPublicKey,
);
buildProgress.complete();
} on ArtifactBuildException catch (error) {
@@ -158,7 +153,9 @@ Looked in:
final patchArtifact = File(patchArtifactPath);
final hash = sha256.convert(await patchArtifact.readAsBytes()).toString();
final privateKeyFile = argResults.file(CommonArguments.privateKeyArgName);
final privateKeyFile = argResults.file(
CommonArguments.privateKeyArg.name,
);
final hashSignature = privateKeyFile != null
? codeSigner.sign(
message: hash,
@@ -70,6 +70,7 @@ class IosFrameworkPatcher extends Patcher {
@override
Future<void> assertArgsAreValid() async {
await super.assertArgsAreValid();
if (!argResults.wasParsed('release-version')) {
logger.err('Missing required argument: --release-version');
exit(ExitCode.usage.code);
@@ -100,6 +100,7 @@ class IosPatcher extends Patcher {
flavor: flavor,
target: target,
args: argResults.forwardedArgs,
base64PublicKey: argResults.encodedPublicKey,
);
} on ProcessException catch (error) {
buildProgress.fail('Failed to build: ${error.message}');
@@ -213,7 +214,7 @@ class IosPatcher extends Patcher {
}
final patchFileSize = patchFile.statSync().size;
final privateKeyFile = argResults.file(CommonArguments.privateKeyArgName);
final privateKeyFile = argResults.file(CommonArguments.privateKeyArg.name);
final hash = sha256.convert(patchBuildFile.readAsBytesSync()).toString();
final hashSignature = privateKeyFile != null
? codeSigner.sign(
@@ -95,11 +95,14 @@ of the iOS app that is using this module.''',
help: 'Validate but do not upload the patch.',
)
..addOption(
CommonArguments.privateKeyArgName,
CommonArguments.privateKeyArg.name,
hide: true,
help: '''
The path for a private key file that will be used to sign the patch artifact.
''',
help: CommonArguments.privateKeyArg.description,
)
..addOption(
CommonArguments.publicKeyArg.name,
hide: true,
help: CommonArguments.publicKeyArg.description,
);
}
@@ -2,9 +2,11 @@ import 'dart:io';
import 'package:args/args.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/release_type.dart';
@@ -66,8 +68,14 @@ https://docs.shorebird.dev/status#link-percentage-ios
/// Asserts that the command can be run.
Future<void> assertPreconditions();
@mustCallSuper
/// Asserts that the combination arguments passed to the command are valid.
Future<void> assertArgsAreValid() async {}
Future<void> assertArgsAreValid() async {
// TODO(erickzanardo): Move this call to the patch_command so we don't need
// to required sub classes to call super.
argResults.assertAbsentOrValidKeyPair();
}
/// Builds the release artifacts for the given platform. Returns the "primary"
/// artifact for the platform (e.g. the AAB for Android, the IPA for iOS).
@@ -117,11 +117,9 @@ of the iOS app that is using this module.''',
allowed: Arch.values.map((arch) => arch.targetPlatformCliArg),
)
..addOption(
CommonArguments.publicKeyArgName,
CommonArguments.publicKeyArg.name,
hide: true,
help: '''
The path for a public key file that will be used to validate patch signatures.
''',
help: CommonArguments.publicKeyArg.description,
);
}
@@ -1,6 +1,33 @@
/// {@template argument_describer}
/// A class that describes an argument from a command/sub command.
/// {@endtemplate}
class ArgumentDescriber {
const ArgumentDescriber({
required this.name,
required this.description,
});
/// Argument name as how the user writes it.
final String name;
/// Argument description that will be shown in the help of the command.
final String description;
}
/// A class that houses the name of arguments that are shared between different
/// commands and layers.
class CommonArguments {
static const String publicKeyArgName = 'public-key-path';
static const String privateKeyArgName = 'private-key-path';
static const publicKeyArg = ArgumentDescriber(
name: 'public-key-path',
description: '''
The path for a public key file that will be used to validate patch signatures.
''',
);
static const privateKeyArg = ArgumentDescriber(
name: 'private-key-path',
description: '''
The path for a private key file that will be used to sign the patch artifact.
''',
);
}
@@ -1,10 +1,12 @@
import 'dart:io';
import 'package:args/args.dart';
import 'package:collection/collection.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/code_signer.dart';
import 'package:shorebird_cli/src/common_arguments.dart';
import 'package:shorebird_cli/src/extensions/file.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/io.dart';
extension OptionFinder on ArgResults {
/// // Detects flags even when passed to underlying commands via a `--`
@@ -49,12 +51,33 @@ extension CodeSign on ArgResults {
/// Asserts that either there is no public key argument
/// or that the path received exists.
void assertAbsentOrValidPublicKey() {
file(CommonArguments.publicKeyArgName)?.assertExists();
file(CommonArguments.publicKeyArg.name)?.assertExists();
}
/// Asserts that either there is no private key argument
/// or that the path received exists.
void assertAbsentOrValidPrivateKey() {
file(CommonArguments.privateKeyArg.name)?.assertExists();
}
/// Asserts that both public and private keys are either absent or
/// when provided, that both of them are pointing to existing files.
void assertAbsentOrValidKeyPair() {
final publicKeyWasParsed = wasParsed(CommonArguments.publicKeyArg.name);
final privateKeyWasParsed = wasParsed(CommonArguments.privateKeyArg.name);
if (publicKeyWasParsed == privateKeyWasParsed) {
assertAbsentOrValidPublicKey();
assertAbsentOrValidPrivateKey();
} else {
logger.err('Both public and private keys must be provided or absent.');
exit(ExitCode.usage.code);
}
}
/// Read the public key file and encode it to base64 if any.
String? get encodedPublicKey {
final publicKeyFile = file(CommonArguments.publicKeyArgName);
final publicKeyFile = file(CommonArguments.publicKeyArg.name);
return publicKeyFile != null
? codeSigner.base64PublicKey(publicKeyFile)
@@ -29,6 +29,7 @@ import 'package:shorebird_cli/src/version.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
import 'package:test/test.dart';
import '../../helpers.dart';
import '../../matchers.dart';
import '../../mocks.dart';
@@ -158,12 +159,6 @@ void main() {
});
});
group('assertArgsAreValid', () {
test('does nothing', () async {
await expectLater(patcher.assertArgsAreValid(), completes);
});
});
group('assertPreconditions', () {
setUp(() {
when(() => doctor.androidCommandValidators)
@@ -246,6 +241,7 @@ void main() {
target: any(named: 'target'),
targetPlatforms: any(named: 'targetPlatforms'),
args: any(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).thenAnswer((_) async => aabFile);
});
@@ -319,6 +315,38 @@ Looked in:
});
});
group('when the key pair is provided', () {
setUp(() {
when(() => codeSigner.base64PublicKey(any()))
.thenReturn('public_key_encoded');
});
test('calls buildIpa with the provided key', () async {
when(() => argResults.wasParsed(CommonArguments.publicKeyArg.name))
.thenReturn(true);
final key = createTempFile('public.der')
..writeAsStringSync('public_key');
when(() => argResults[CommonArguments.publicKeyArg.name])
.thenReturn(key.path);
when(() => argResults[CommonArguments.publicKeyArg.name])
.thenReturn(key.path);
await runWithOverrides(
patcher.buildPatchArtifact,
);
verify(
() => artifactBuilder.buildAppBundle(
args: any(named: 'args'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
base64PublicKey: 'public_key_encoded',
),
).called(1);
});
});
test('returns the aab file', () async {
final result = await runWithOverrides(patcher.buildPatchArtifact);
expect(result, equals(aabFile));
@@ -467,7 +495,7 @@ Looked in:
),
)..createSync();
when(() => argResults[CommonArguments.privateKeyArgName])
when(() => argResults[CommonArguments.privateKeyArg.name])
.thenReturn(privateKey.path);
when(
@@ -9,6 +9,7 @@ import 'package:shorebird_cli/src/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/patch/patch.dart';
import 'package:shorebird_cli/src/common_arguments.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/engine_config.dart';
import 'package:shorebird_cli/src/executables/aot_tools.dart';
@@ -115,6 +116,10 @@ void main() {
when(() => argResults['build-number']).thenReturn('1.0');
when(() => argResults.rest).thenReturn([]);
when(() => argResults.wasParsed(CommonArguments.privateKeyArg.name))
.thenReturn(false);
when(() => argResults.wasParsed(CommonArguments.publicKeyArg.name))
.thenReturn(false);
when(() => logger.progress(any())).thenReturn(progress);
@@ -33,6 +33,7 @@ import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
import 'package:test/test.dart';
import '../../fakes.dart';
import '../../helpers.dart';
import '../../matchers.dart';
import '../../mocks.dart';
@@ -143,12 +144,6 @@ void main() {
);
});
group('assertArgsAreValid', () {
test('has no specific validations', () {
expect(patcher.assertArgsAreValid, returnsNormally);
});
});
group('archiveDiffer', () {
test('is an IosArchiveDiffer', () {
expect(patcher.archiveDiffer, isA<IosArchiveDiffer>());
@@ -389,6 +384,7 @@ void main() {
args: any(named: 'args'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).thenAnswer((_) async {});
when(() => artifactManager.getXcarchiveDirectory()).thenReturn(
@@ -436,6 +432,41 @@ void main() {
});
});
group('when the key pair is provided', () {
setUp(() {
when(() => codeSigner.base64PublicKey(any()))
.thenReturn('public_key_encoded');
});
test('calls the buildIpa passing the key', () async {
when(
() => argResults.wasParsed(CommonArguments.publicKeyArg.name),
).thenReturn(true);
final key = createTempFile('public.pem')
..writeAsStringSync('public_key');
when(() => argResults[CommonArguments.publicKeyArg.name])
.thenReturn(key.path);
when(() => argResults[CommonArguments.publicKeyArg.name])
.thenReturn(key.path);
await runWithOverrides(
patcher.buildPatchArtifact,
);
verify(
() => artifactBuilder.buildIpa(
exportOptionsPlist: any(named: 'exportOptionsPlist'),
codesign: any(named: 'codesign'),
args: any(named: 'args'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
base64PublicKey: 'public_key_encoded',
),
).called(1);
});
});
test('returns xcarchive zip', () async {
final artifact = await runWithOverrides(patcher.buildPatchArtifact);
expect(p.basename(artifact.path), endsWith('.zip'));
@@ -941,7 +972,7 @@ void main() {
});
});
group('when a private key is provided', () {
group('when code signing the patch', () {
setUp(() {
final privateKey = File(
p.join(
@@ -950,7 +981,7 @@ void main() {
),
)..createSync();
when(() => argResults[CommonArguments.privateKeyArgName])
when(() => argResults[CommonArguments.privateKeyArg.name])
.thenReturn(privateKey.path);
when(
@@ -1,14 +1,21 @@
import 'dart:io';
import 'package:args/args.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:shorebird_cli/src/common_arguments.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_code_push_protocol/src/models/create_patch_metadata.dart';
import 'package:test/test.dart';
import '../../helpers.dart';
import '../../matchers.dart';
import '../../mocks.dart';
void main() {
@@ -25,6 +32,119 @@ void main() {
);
});
});
group('assertArgsAreValid', () {
late _TestPatcher patcher;
late ArgResults argResults;
late ShorebirdLogger logger;
R runWithOverrides<R>(R Function() body) {
return runScoped(
body,
values: {
loggerRef.overrideWith(() => logger),
},
);
}
setUp(() {
setExitFunctionForTests();
argResults = MockArgResults();
logger = MockShorebirdLogger();
patcher = _TestPatcher(
argResults: argResults,
flavor: null,
target: null,
);
when(() => argResults.wasParsed(CommonArguments.publicKeyArg.name))
.thenReturn(false);
when(() => argResults.wasParsed(CommonArguments.privateKeyArg.name))
.thenReturn(false);
});
group('when no key pair is provided', () {
test('is valid', () {
expect(
runWithOverrides(patcher.assertArgsAreValid),
completes,
);
});
});
group(
'when given existing private and public key files',
() {
test('is valid', () async {
when(
() => argResults.wasParsed(CommonArguments.privateKeyArg.name),
).thenReturn(true);
when(() => argResults.wasParsed(CommonArguments.publicKeyArg.name))
.thenReturn(true);
when(() => argResults[CommonArguments.privateKeyArg.name])
.thenReturn(createTempFile('private.pem').path);
when(() => argResults[CommonArguments.publicKeyArg.name])
.thenReturn(createTempFile('public.pem').path);
expect(
runWithOverrides(patcher.assertArgsAreValid),
completes,
);
});
},
);
group(
'when given an existing private key and nonexistent public key',
() {
test('logs error and exits with usage code', () async {
when(
() => argResults.wasParsed(CommonArguments.privateKeyArg.name),
).thenReturn(true);
when(() => argResults.wasParsed(CommonArguments.publicKeyArg.name))
.thenReturn(false);
when(() => argResults[CommonArguments.privateKeyArg.name])
.thenReturn(createTempFile('private.pem').path);
await expectLater(
() => runWithOverrides(patcher.assertArgsAreValid),
exitsWithCode(ExitCode.usage),
);
verify(
() => logger.err(
'Both public and private keys must be provided or absent.',
),
).called(1);
});
},
);
group(
'when given an existing public key and nonexistent private key',
() {
test('fails and logs the err', () async {
when(
() => argResults.wasParsed(CommonArguments.privateKeyArg.name),
).thenReturn(false);
when(() => argResults.wasParsed(CommonArguments.publicKeyArg.name))
.thenReturn(true);
when(() => argResults[CommonArguments.publicKeyArg.name])
.thenReturn(createTempFile('public.pem').path);
await expectLater(
() => runWithOverrides(patcher.assertArgsAreValid),
exitsWithCode(ExitCode.usage),
);
verify(
() => logger.err(
'Both public and private keys must be provided or absent.',
),
).called(1);
});
},
);
});
});
}
@@ -227,7 +227,7 @@ void main() {
'public-key.pem',
),
)..writeAsStringSync('public key');
when(() => argResults[CommonArguments.publicKeyArgName])
when(() => argResults[CommonArguments.publicKeyArg.name])
.thenReturn(publicKeyFile.path);
});
@@ -242,7 +242,7 @@ void main() {
group('when a public key is provided but does not exist', () {
setUp(() {
when(() => argResults['artifact']).thenReturn('apk');
when(() => argResults[CommonArguments.publicKeyArgName])
when(() => argResults[CommonArguments.publicKeyArg.name])
.thenReturn('non-existing-key.pem');
});
@@ -434,7 +434,7 @@ void main() {
'patch-signing-public-key.pem',
),
)..createSync(recursive: true);
when(() => argResults[CommonArguments.publicKeyArgName])
when(() => argResults[CommonArguments.publicKeyArg.name])
.thenReturn(patchSigningPublicKeyFile.path);
when(
@@ -258,7 +258,7 @@ void main() {
'public-key.pem',
),
)..writeAsStringSync('public key');
when(() => argResults[CommonArguments.publicKeyArgName])
when(() => argResults[CommonArguments.publicKeyArg.name])
.thenReturn(publicKeyFile.path);
});
@@ -272,7 +272,7 @@ void main() {
group('when the provided public key is a nonexistent file', () {
setUp(() {
when(() => argResults[CommonArguments.publicKeyArgName])
when(() => argResults[CommonArguments.publicKeyArg.name])
.thenReturn('non-existing-key.pem');
});
@@ -339,7 +339,7 @@ void main() {
'patch-signing-public-key.pem',
),
)..createSync(recursive: true);
when(() => argResults[CommonArguments.publicKeyArgName])
when(() => argResults[CommonArguments.publicKeyArg.name])
.thenReturn(patchSigningPublicKeyFile.path);
when(
@@ -501,7 +501,7 @@ $exception''',
keyName,
),
)..writeAsStringSync('KEY');
when(() => argResults[CommonArguments.publicKeyArgName])
when(() => argResults[CommonArguments.publicKeyArg.name])
.thenReturn(file.path);
});
@@ -0,0 +1,11 @@
import 'dart:io';
import 'package:path/path.dart' as p;
File createTempFile(String name) {
return File(
p.join(
Directory.systemTemp.createTempSync().path,
name,
),
)..createSync();
}