feat(shorebird_cli): add --public-key-cmd and --sign-cmd support (#3605)

This commit is contained in:
Eric Seidel
2026-02-06 11:57:50 -08:00
committed by GitHub
parent eb05688a5d
commit 9ffe18737a
41 changed files with 1750 additions and 100 deletions
+29
View File
@@ -0,0 +1,29 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build Commands
```bash
# Run all tests (must run from packages/ directory to avoid cache conflicts)
cd packages && very_good test -r
# Run tests for a single package (can use -r failures-only to reduce output)
dart test packages/shorebird_cli
```
## Architecture
Dart monorepo. Main package is `shorebird_cli`.
**Platform-specific operations** use the Releaser/Patcher pattern:
- `commands/release/` - `Releaser` base class with platform implementations
- `commands/patch/` - `Patcher` base class with platform implementations
**Dependency injection** uses `scoped_deps` with zone-based refs (see any `*Ref` variable).
## Code Style
- PR titles must follow semantic commit format (enforced in CI)
- CSpell: use inline `// cspell:words` for 1-2 files; add to global config for more
- Prefer new commits over amending in PRs - history gets squashed anyway
+105 -6
View File
@@ -6,6 +6,7 @@ import 'dart:typed_data';
import 'package:pem/pem.dart'; import 'package:pem/pem.dart';
import 'package:pointycastle/pointycastle.dart'; import 'package:pointycastle/pointycastle.dart';
import 'package:scoped_deps/scoped_deps.dart'; import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
/// A reference to a [CodeSigner] instance. /// A reference to a [CodeSigner] instance.
final codeSignerRef = create(CodeSigner.new); final codeSignerRef = create(CodeSigner.new);
@@ -53,8 +54,13 @@ class CodeSigner {
/// simply the modulus and exponent of the public key, without information /// simply the modulus and exponent of the public key, without information
/// about the algorithm or or ASN1 object type identifier. /// about the algorithm or or ASN1 object type identifier.
String base64PublicKey(File publicKeyPemFile) { String base64PublicKey(File publicKeyPemFile) {
return base64PublicKeyFromPem(publicKeyPemFile.readAsStringSync());
}
/// Extracts the base64 encoded DER from a PEM-encoded public key string.
String base64PublicKeyFromPem(String publicKeyPem) {
final publicKey = _RSAPublicKeyFromBytes.rsaPublicKeyFromBytes( final publicKey = _RSAPublicKeyFromBytes.rsaPublicKeyFromBytes(
_pemBytes(pemFile: publicKeyPemFile, type: PemLabel.publicKey), PemCodec(PemLabel.publicKey).decode(publicKeyPem),
); );
final publicKeySeq = ASN1Sequence() final publicKeySeq = ASN1Sequence()
@@ -64,11 +70,104 @@ class CodeSigner {
return base64.encode(publicKeySeq.encodedBytes!); return base64.encode(publicKeySeq.encodedBytes!);
} }
/// Reads a PEM file containing a key of type [type] and returns its contents /// Runs a command and returns its stdout, expected to be a PEM public key.
/// as bytes. Future<String> runPublicKeyCmd(String command) async {
List<int> _pemBytes({required File pemFile, required PemLabel type}) { final result = await process.run(
final privateKeyString = pemFile.readAsStringSync(); 'sh',
return PemCodec(type).decode(privateKeyString); ['-c', command],
);
if (result.exitCode != 0) {
throw ProcessException(
command,
[],
'Command failed with exit code ${result.exitCode}: ${result.stderr}',
result.exitCode,
);
}
final output = '${result.stdout}'.trim();
if (!output.contains('-----BEGIN') || !output.contains('PUBLIC KEY')) {
if (output.isEmpty) {
throw const FormatException(
'Command produced no output. '
'Expected a PEM-encoded public key.',
);
}
final preview = output.length > 100
? '${output.substring(0, 100)}...'
: output;
throw FormatException(
'Command output does not appear to be a PEM-encoded public key: '
'$preview',
);
}
return output;
}
/// Signs data by piping it to a command's stdin and reading the base64
/// signature from stdout.
Future<String> signWithCmd({
required String data,
required String command,
}) async {
final proc = await process.start(
'sh',
['-c', command],
);
// Write data to stdin and close it
proc.stdin.write(data);
await proc.stdin.close();
// Read stdout and stderr concurrently to avoid potential deadlock
final results = await Future.wait([
proc.stdout.transform(utf8.decoder).join(),
proc.stderr.transform(utf8.decoder).join(),
]);
final stdout = results[0];
final stderr = results[1];
final exitCode = await proc.exitCode;
if (exitCode != 0) {
throw ProcessException(
command,
[],
'Sign command failed with exit code $exitCode: $stderr',
exitCode,
);
}
final signature = stdout.trim();
if (signature.isEmpty) {
throw const FormatException('Sign command produced no output');
}
return signature;
}
/// Verifies a signature against a message using a PEM-encoded public key.
bool verify({
required String message,
required String signature,
required String publicKeyPem,
}) {
final publicKey = _RSAPublicKeyFromBytes.rsaPublicKeyFromBytes(
PemCodec(PemLabel.publicKey).decode(publicKeyPem),
);
final signer = Signer('SHA-256/RSA')
..init(false, PublicKeyParameter<RSAPublicKey>(publicKey));
try {
return signer.verifySignature(
utf8.encode(message),
RSASignature(base64.decode(signature)),
);
} on Exception {
return false;
}
} }
/// Parses a PEM-encoded private key string and returns the key bytes along /// Parses a PEM-encoded private key string and returns the key bytes along
@@ -9,9 +9,7 @@ import 'package:shorebird_cli/src/archive_analysis/android_archive_differ.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/code_signer.dart';
import 'package:shorebird_cli/src/commands/patch/patch.dart'; import 'package:shorebird_cli/src/commands/patch/patch.dart';
import 'package:shorebird_cli/src/common_arguments.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart'; import 'package:shorebird_cli/src/patch_diff_checker.dart';
@@ -143,13 +141,7 @@ class AarPatcher extends Patcher {
logger.detail('Creating artifact for $artifactPath'); logger.detail('Creating artifact for $artifactPath');
final patchArtifact = File(artifactPath); final patchArtifact = File(artifactPath);
final hash = sha256.convert(await patchArtifact.readAsBytes()).toString(); final hash = sha256.convert(await patchArtifact.readAsBytes()).toString();
final hashSignature = await signHash(hash);
final privateKeyFile = argResults.file(
CommonArguments.privateKeyArg.name,
);
final hashSignature = privateKeyFile != null
? codeSigner.sign(message: hash, privateKeyPemFile: privateKeyFile)
: null;
try { try {
final diffPath = await artifactManager.createDiff( final diffPath = await artifactManager.createDiff(
@@ -9,9 +9,7 @@ import 'package:shorebird_cli/src/archive_analysis/android_archive_differ.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/code_signer.dart';
import 'package:shorebird_cli/src/commands/patch/patcher.dart'; 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/doctor.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/logging/logging.dart';
@@ -189,13 +187,7 @@ Please refer to ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebir
logger.detail('Creating artifact for $patchArtifactPath'); logger.detail('Creating artifact for $patchArtifactPath');
final patchArtifact = File(patchArtifactPath); final patchArtifact = File(patchArtifactPath);
final hash = sha256.convert(await patchArtifact.readAsBytes()).toString(); final hash = sha256.convert(await patchArtifact.readAsBytes()).toString();
final hashSignature = await signHash(hash);
final privateKeyFile = argResults.file(
CommonArguments.privateKeyArg.name,
);
final hashSignature = privateKeyFile != null
? codeSigner.sign(message: hash, privateKeyPemFile: privateKeyFile)
: null;
try { try {
final diffPath = await artifactManager.createDiff( final diffPath = await artifactManager.createDiff(
@@ -10,9 +10,7 @@ import 'package:shorebird_cli/src/archive_analysis/apple_archive_differ.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/code_signer.dart';
import 'package:shorebird_cli/src/commands/patch/patch.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/doctor.dart';
import 'package:shorebird_cli/src/executables/aot_tools.dart'; import 'package:shorebird_cli/src/executables/aot_tools.dart';
import 'package:shorebird_cli/src/executables/xcodebuild.dart'; import 'package:shorebird_cli/src/executables/xcodebuild.dart';
@@ -232,11 +230,8 @@ class IosFrameworkPatcher extends Patcher {
} }
final patchFileSize = patchFile.statSync().size; final patchFileSize = patchFile.statSync().size;
final privateKeyFile = argResults.file(CommonArguments.privateKeyArg.name);
final hash = sha256.convert(patchBuildFile.readAsBytesSync()).toString(); final hash = sha256.convert(patchBuildFile.readAsBytesSync()).toString();
final hashSignature = privateKeyFile != null final hashSignature = await signHash(hash);
? codeSigner.sign(message: hash, privateKeyPemFile: privateKeyFile)
: null;
return { return {
Arch.arm64: PatchArtifactBundle( Arch.arm64: PatchArtifactBundle(
@@ -11,9 +11,7 @@ import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/code_signer.dart';
import 'package:shorebird_cli/src/commands/patch/patcher.dart'; 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/doctor.dart';
import 'package:shorebird_cli/src/executables/executables.dart'; import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart';
@@ -304,11 +302,8 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
} }
final patchFileSize = patchFile.statSync().size; final patchFileSize = patchFile.statSync().size;
final privateKeyFile = argResults.file(CommonArguments.privateKeyArg.name);
final hash = sha256.convert(patchBuildFile.readAsBytesSync()).toString(); final hash = sha256.convert(patchBuildFile.readAsBytesSync()).toString();
final hashSignature = privateKeyFile != null final hashSignature = await signHash(hash);
? codeSigner.sign(message: hash, privateKeyPemFile: privateKeyFile)
: null;
return { return {
Arch.arm64: PatchArtifactBundle( Arch.arm64: PatchArtifactBundle(
@@ -8,9 +8,7 @@ import 'package:shorebird_cli/src/archive_analysis/linux_bundle_differ.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/code_signer.dart';
import 'package:shorebird_cli/src/commands/commands.dart'; import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:shorebird_cli/src/common_arguments.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart'; import 'package:shorebird_cli/src/patch_diff_checker.dart';
@@ -85,10 +83,7 @@ class LinuxPatcher extends Patcher {
// build/linux/x64/release/bundle // build/linux/x64/release/bundle
final appSoPath = p.join(tempDir.path, 'lib', 'libapp.so'); final appSoPath = p.join(tempDir.path, 'lib', 'libapp.so');
final privateKeyFile = argResults.file(CommonArguments.privateKeyArg.name); final hashSignature = await signHash(hash);
final hashSignature = privateKeyFile != null
? codeSigner.sign(message: hash, privateKeyPemFile: privateKeyFile)
: null;
final String diffPath; final String diffPath;
try { try {
@@ -8,9 +8,7 @@ import 'package:shorebird_cli/src/archive_analysis/apple_archive_differ.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/code_signer.dart';
import 'package:shorebird_cli/src/commands/patch/patch.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/doctor.dart';
import 'package:shorebird_cli/src/executables/executables.dart'; import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart';
@@ -201,11 +199,8 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
final patchFile = File(patchFilePath); final patchFile = File(patchFilePath);
final patchFileSize = patchFile.statSync().size; final patchFileSize = patchFile.statSync().size;
final privateKeyFile = argResults.file(CommonArguments.privateKeyArg.name);
final hash = sha256.convert(patchArtifact.readAsBytesSync()).toString(); final hash = sha256.convert(patchArtifact.readAsBytesSync()).toString();
final hashSignature = privateKeyFile != null final hashSignature = await signHash(hash);
? codeSigner.sign(message: hash, privateKeyPemFile: privateKeyFile)
: null;
return PatchArtifactBundle( return PatchArtifactBundle(
arch: arch.arch, arch: arch.arch,
@@ -140,6 +140,14 @@ To target the latest release (e.g. the release that was most recently updated) u
CommonArguments.publicKeyArg.name, CommonArguments.publicKeyArg.name,
help: CommonArguments.publicKeyArg.description, help: CommonArguments.publicKeyArg.description,
) )
..addOption(
CommonArguments.publicKeyCmd.name,
help: CommonArguments.publicKeyCmd.description,
)
..addOption(
CommonArguments.signCmd.name,
help: CommonArguments.signCmd.description,
)
..addOption( ..addOption(
CommonArguments.splitDebugInfoArg.name, CommonArguments.splitDebugInfoArg.name,
help: CommonArguments.splitDebugInfoArg.description, help: CommonArguments.splitDebugInfoArg.description,
@@ -287,7 +295,7 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl
Future<void> createPatch(Patcher patcher) async { Future<void> createPatch(Patcher patcher) async {
await patcher.assertPreconditions(); await patcher.assertPreconditions();
await patcher.assertArgsAreValid(); await patcher.assertArgsAreValid();
results.assertAbsentOrValidKeyPair(); results.assertAbsentOrValidKeyPairOrCommands();
try { try {
await shorebirdValidator.validateFlavors( await shorebirdValidator.validateFlavors(
@@ -444,7 +452,9 @@ Building patch with Flutter $flutterVersionString
usedIgnoreNativeChangesFlag: allowNativeDiffs, usedIgnoreNativeChangesFlag: allowNativeDiffs,
hasNativeChanges: diffStatus.hasNativeChanges, hasNativeChanges: diffStatus.hasNativeChanges,
inferredReleaseVersion: inferredReleaseVersion, inferredReleaseVersion: inferredReleaseVersion,
isSigned: results.wasParsed(CommonArguments.privateKeyArg.name), isSigned:
results.wasParsed(CommonArguments.privateKeyArg.name) ||
results.wasParsed(CommonArguments.signCmd.name),
environment: BuildEnvironmentMetadata( environment: BuildEnvironmentMetadata(
flutterRevision: shorebirdEnv.flutterRevision, flutterRevision: shorebirdEnv.flutterRevision,
operatingSystem: platform.operatingSystem, operatingSystem: platform.operatingSystem,
@@ -4,16 +4,19 @@ import 'package:args/args.dart';
import 'package:mason_logger/mason_logger.dart'; import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/code_signer.dart';
import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/common_arguments.dart';
import 'package:shorebird_cli/src/deployment_track.dart'; import 'package:shorebird_cli/src/deployment_track.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/extensions/iterable.dart'; import 'package:shorebird_cli/src/extensions/iterable.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/metadata/metadata.dart'; import 'package:shorebird_cli/src/metadata/metadata.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart'; import 'package:shorebird_cli/src/patch_diff_checker.dart';
import 'package:shorebird_cli/src/platform/platform.dart'; import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/release_type.dart'; import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/shorebird_documentation.dart'; import 'package:shorebird_cli/src/shorebird_documentation.dart';
import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
@@ -127,6 +130,90 @@ More info: ${troubleshootingUrl.toLink()}.
/// Whether to allow changes in native code (--allow-native-diffs). /// Whether to allow changes in native code (--allow-native-diffs).
bool get allowNativeDiffs => argResults['allow-native-diffs'] == true; bool get allowNativeDiffs => argResults['allow-native-diffs'] == true;
/// Returns a function that signs data, or null if no signing is configured.
Future<String> Function(String)? _resolveSigner() {
final privateKeyFile = argResults.file(CommonArguments.privateKeyArg.name);
if (privateKeyFile != null) {
return (hash) async =>
codeSigner.sign(message: hash, privateKeyPemFile: privateKeyFile);
}
final signCmd = argResults[CommonArguments.signCmd.name] as String?;
if (signCmd != null) {
return (hash) async {
try {
return await codeSigner.signWithCmd(data: hash, command: signCmd);
} on ProcessException catch (e) {
logger.err(
'Failed to run --${CommonArguments.signCmd.name}: ${e.message}',
);
throw ProcessExit(ExitCode.software.code);
} on FormatException catch (e) {
logger.err(
'--${CommonArguments.signCmd.name} produced invalid output: '
'${e.message}',
);
throw ProcessExit(ExitCode.software.code);
}
};
}
return null;
}
/// Returns the public key PEM from the configured source, or null.
Future<String?> _resolvePublicKeyPem() async {
try {
return await argResults.resolvePublicKeyPem();
} on ProcessException catch (e) {
logger.err(
'Failed to run '
'--${CommonArguments.publicKeyCmd.name}: ${e.message}',
);
throw ProcessExit(ExitCode.software.code);
} on FormatException catch (e) {
logger.err(
'--${CommonArguments.publicKeyCmd.name} produced invalid output: '
'${e.message}',
);
throw ProcessExit(ExitCode.software.code);
}
}
/// Signs a hash using the configured signing method.
///
/// Returns null if no signing is configured.
Future<String?> signHash(String hash) async {
final signer = _resolveSigner();
if (signer == null) return null;
final signature = await signer(hash);
final publicKeyPem = await _resolvePublicKeyPem();
if (publicKeyPem == null) {
logger.err(
'A public key is required for code signing. '
'Provide --${CommonArguments.publicKeyArg.name} or '
'--${CommonArguments.publicKeyCmd.name}.',
);
throw ProcessExit(ExitCode.usage.code);
}
if (!codeSigner.verify(
message: hash,
signature: signature,
publicKeyPem: publicKeyPem,
)) {
logger.err(
'Signature verification failed. The signature does not match '
'the provided public key.',
);
throw ProcessExit(ExitCode.software.code);
}
return signature;
}
/// The link percentage for the generated patch artifact if applicable. /// The link percentage for the generated patch artifact if applicable.
/// Returns `null` if the platform does not use a linker or if the linking /// Returns `null` if the platform does not use a linker or if the linking
/// step has not yet been run. /// step has not yet been run.
@@ -9,9 +9,7 @@ import 'package:shorebird_cli/src/archive_analysis/windows_archive_differ.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/code_signer.dart';
import 'package:shorebird_cli/src/commands/patch/patcher.dart'; 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/doctor.dart';
import 'package:shorebird_cli/src/executables/executables.dart'; import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart';
@@ -109,10 +107,7 @@ class WindowsPatcher extends Patcher {
// build/windows/x64/runner/Release // build/windows/x64/runner/Release
final appSoPath = p.join(tempDir.path, 'data', 'app.so'); final appSoPath = p.join(tempDir.path, 'data', 'app.so');
final privateKeyFile = argResults.file(CommonArguments.privateKeyArg.name); final hashSignature = await signHash(hash);
final hashSignature = privateKeyFile != null
? codeSigner.sign(message: hash, privateKeyPemFile: privateKeyFile)
: null;
final String diffPath; final String diffPath;
try { try {
@@ -74,11 +74,12 @@ class AarReleaser extends Releaser {
@override @override
Future<FileSystemEntity> buildReleaseArtifacts() async { Future<FileSystemEntity> buildReleaseArtifacts() async {
final base64PublicKey = await getEncodedPublicKey();
await artifactBuilder.buildAar( await artifactBuilder.buildAar(
buildNumber: buildNumber, buildNumber: buildNumber,
targetPlatforms: architectures, targetPlatforms: architectures,
args: argResults.forwardedArgs, args: argResults.forwardedArgs,
base64PublicKey: argResults.encodedPublicKey, base64PublicKey: base64PublicKey,
); );
// Copy release AAR to a new directory to avoid overwriting with // Copy release AAR to a new directory to avoid overwriting with
@@ -102,7 +102,7 @@ Please comment and upvote ${link(uri: Uri.parse('https://github.com/shorebirdtec
@override @override
Future<FileSystemEntity> buildReleaseArtifacts() async { Future<FileSystemEntity> buildReleaseArtifacts() async {
final base64PublicKey = argResults.encodedPublicKey; final base64PublicKey = await getEncodedPublicKey();
final aab = await artifactBuilder.buildAppBundle( final aab = await artifactBuilder.buildAppBundle(
flavor: flavor, flavor: flavor,
target: target, target: target,
@@ -78,9 +78,10 @@ class IosFrameworkReleaser extends Releaser {
shorebirdSupplementDir!.deleteSync(recursive: true); shorebirdSupplementDir!.deleteSync(recursive: true);
} }
final base64PublicKey = await getEncodedPublicKey();
await artifactBuilder.buildIosFramework( await artifactBuilder.buildIosFramework(
args: argResults.forwardedArgs, args: argResults.forwardedArgs,
base64PublicKey: argResults.encodedPublicKey, base64PublicKey: base64PublicKey,
); );
// Copy release xcframework to a new directory to avoid overwriting with // Copy release xcframework to a new directory to avoid overwriting with
@@ -102,12 +102,13 @@ To change the version of this release, change your app's version in your pubspec
shorebirdSupplementDir!.deleteSync(recursive: true); shorebirdSupplementDir!.deleteSync(recursive: true);
} }
final base64PublicKey = await getEncodedPublicKey();
await artifactBuilder.buildIpa( await artifactBuilder.buildIpa(
codesign: codesign, codesign: codesign,
flavor: flavor, flavor: flavor,
target: target, target: target,
args: argResults.forwardedArgs, args: argResults.forwardedArgs,
base64PublicKey: argResults.encodedPublicKey, base64PublicKey: base64PublicKey,
); );
final xcarchiveDirectory = artifactManager.getXcarchiveDirectory(); final xcarchiveDirectory = artifactManager.getXcarchiveDirectory();
@@ -64,10 +64,11 @@ To change the version of this release, change your app's version in your pubspec
@override @override
Future<FileSystemEntity> buildReleaseArtifacts() async { Future<FileSystemEntity> buildReleaseArtifacts() async {
final base64PublicKey = await getEncodedPublicKey();
await artifactBuilder.buildLinuxApp( await artifactBuilder.buildLinuxApp(
target: target, target: target,
args: argResults.forwardedArgs, args: argResults.forwardedArgs,
base64PublicKey: argResults.encodedPublicKey, base64PublicKey: base64PublicKey,
); );
return artifactManager.linuxBundleDirectory; return artifactManager.linuxBundleDirectory;
@@ -93,12 +93,13 @@ To change the version of this release, change your app's version in your pubspec
); );
} }
final base64PublicKey = await getEncodedPublicKey();
await artifactBuilder.buildMacos( await artifactBuilder.buildMacos(
codesign: codesign, codesign: codesign,
flavor: flavor, flavor: flavor,
target: target, target: target,
args: argResults.forwardedArgs, args: argResults.forwardedArgs,
base64PublicKey: argResults.encodedPublicKey, base64PublicKey: base64PublicKey,
); );
final appDirectory = artifactManager.getMacOSAppDirectory(flavor: flavor); final appDirectory = artifactManager.getMacOSAppDirectory(flavor: flavor);
@@ -145,6 +145,10 @@ of the iOS app that is using this module. (aar and ios-framework only)''',
CommonArguments.publicKeyArg.name, CommonArguments.publicKeyArg.name,
help: CommonArguments.publicKeyArg.description, help: CommonArguments.publicKeyArg.description,
) )
..addOption(
CommonArguments.publicKeyCmd.name,
help: CommonArguments.publicKeyCmd.description,
)
..addOption( ..addOption(
CommonArguments.splitDebugInfoArg.name, CommonArguments.splitDebugInfoArg.name,
help: CommonArguments.splitDebugInfoArg.description, help: CommonArguments.splitDebugInfoArg.description,
@@ -367,14 +371,18 @@ of the iOS app that is using this module. (aar and ios-framework only)''',
/// Validates arguments that are common to all release types. /// Validates arguments that are common to all release types.
Future<void> assertArgsAreValid(Releaser releaser) async { Future<void> assertArgsAreValid(Releaser releaser) async {
results.assertAbsentOrValidPublicKey(); results.assertAbsentOrValidPublicKeyOrCmd();
final shorebirdYaml = shorebirdEnv.getShorebirdYaml(); final shorebirdYaml = shorebirdEnv.getShorebirdYaml();
if (shorebirdYaml?.patchVerification != null && final hasPublicKey =
!results.wasParsed(CommonArguments.publicKeyArg.name)) { results.wasParsed(CommonArguments.publicKeyArg.name) ||
results.wasParsed(CommonArguments.publicKeyCmd.name);
if (shorebirdYaml?.patchVerification != null && !hasPublicKey) {
logger.warn( logger.warn(
'patch_verification is set in shorebird.yaml but ' 'patch_verification is set in shorebird.yaml but '
'--${CommonArguments.publicKeyArg.name} was not provided.\n' 'no public key was provided '
'(--${CommonArguments.publicKeyArg.name} '
'or --${CommonArguments.publicKeyCmd.name}).\n'
'patch_verification configuration will have no effect.', 'patch_verification configuration will have no effect.',
); );
} }
@@ -554,10 +562,13 @@ ${summary.join('\n')}
required Release release, required Release release,
required Releaser releaser, required Releaser releaser,
}) async { }) async {
final hasPublicKey =
results.wasParsed(CommonArguments.publicKeyArg.name) ||
results.wasParsed(CommonArguments.publicKeyCmd.name);
final baseMetadata = UpdateReleaseMetadata( final baseMetadata = UpdateReleaseMetadata(
releasePlatform: releaser.releaseType.releasePlatform, releasePlatform: releaser.releaseType.releasePlatform,
flutterVersionOverride: flutterVersionArg, flutterVersionOverride: flutterVersionArg,
includesPublicKey: results.wasParsed(CommonArguments.publicKeyArg.name), includesPublicKey: hasPublicKey,
environment: BuildEnvironmentMetadata( environment: BuildEnvironmentMetadata(
flutterRevision: shorebirdEnv.flutterRevision, flutterRevision: shorebirdEnv.flutterRevision,
operatingSystem: platform.operatingSystem, operatingSystem: platform.operatingSystem,
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:args/args.dart'; import 'package:args/args.dart';
import 'package:pub_semver/pub_semver.dart'; import 'package:pub_semver/pub_semver.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/metadata/metadata.dart'; import 'package:shorebird_cli/src/metadata/metadata.dart';
import 'package:shorebird_cli/src/release_type.dart'; import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_env.dart';
@@ -81,4 +82,9 @@ abstract class Releaser {
Future<String> getReleaseVersion({ Future<String> getReleaseVersion({
required FileSystemEntity releaseArtifactRoot, required FileSystemEntity releaseArtifactRoot,
}); });
/// Gets the base64-encoded public key from either file or command.
///
/// Returns null if no public key is configured.
Future<String?> getEncodedPublicKey() => argResults.getEncodedPublicKey();
} }
@@ -66,11 +66,12 @@ To change the version of this release, change your app's version in your pubspec
} }
@override @override
Future<FileSystemEntity> buildReleaseArtifacts() { Future<FileSystemEntity> buildReleaseArtifacts() async {
final base64PublicKey = await getEncodedPublicKey();
return artifactBuilder.buildWindowsApp( return artifactBuilder.buildWindowsApp(
target: target, target: target,
args: argResults.forwardedArgs, args: argResults.forwardedArgs,
base64PublicKey: argResults.encodedPublicKey, base64PublicKey: base64PublicKey,
); );
} }
@@ -110,6 +110,25 @@ The path for a private key .pem file that will be used to sign the patch artifac
''', ''',
); );
/// An argument that allows the user to specify a command that outputs a
/// PEM-encoded public key to stdout.
static const publicKeyCmd = ArgumentDescriber(
name: 'public-key-cmd',
description: '''
Command that outputs a PEM-encoded public key to stdout for patch signature validation.
''',
);
/// An argument that allows the user to specify a command that signs data.
/// The command reads data from stdin and outputs a base64 signature to
/// stdout.
static const signCmd = ArgumentDescriber(
name: 'sign-cmd',
description: '''
Command that reads data from stdin and outputs a base64 signature to stdout.
''',
);
/// An argument that allows the user to specify a release version. You will /// An argument that allows the user to specify a release version. You will
/// most likely want to provide a custom description for this argument that /// most likely want to provide a custom description for this argument that
/// more thoroughly explains what the release version is used for. /// more thoroughly explains what the release version is used for.
@@ -73,6 +73,25 @@ extension CodeSign on ArgResults {
} }
} }
/// Resolves the public key PEM string from the configured source.
///
/// Returns null if no public key is configured. Throws
/// [ProcessException] or [FormatException] if a command-based key
/// fails.
Future<String?> resolvePublicKeyPem() async {
final publicKeyFile = file(CommonArguments.publicKeyArg.name);
if (publicKeyFile != null) {
return publicKeyFile.readAsStringSync();
}
final publicKeyCmd = this[CommonArguments.publicKeyCmd.name] as String?;
if (publicKeyCmd != null && wasParsed(CommonArguments.publicKeyCmd.name)) {
return codeSigner.runPublicKeyCmd(publicKeyCmd);
}
return null;
}
/// Read the public key file and encode it to base64 if any. /// Read the public key file and encode it to base64 if any.
String? get encodedPublicKey { String? get encodedPublicKey {
final publicKeyFile = file(CommonArguments.publicKeyArg.name); final publicKeyFile = file(CommonArguments.publicKeyArg.name);
@@ -81,6 +100,121 @@ extension CodeSign on ArgResults {
? codeSigner.base64PublicKey(publicKeyFile) ? codeSigner.base64PublicKey(publicKeyFile)
: null; : null;
} }
/// Validates key arguments for patch commands.
///
/// Valid configurations:
/// - No signing (nothing provided)
/// - File-based: --public-key-path + --private-key-path
/// - Command-based: --public-key-cmd + --sign-cmd
/// - Mixed: --public-key-path + --sign-cmd
///
/// Invalid configurations:
/// - Both --public-key-path and --public-key-cmd (ambiguous public key)
/// - Both --private-key-path and --sign-cmd (ambiguous signing method)
/// - --sign-cmd without a public key source
/// - --private-key-path without --public-key-path
void assertAbsentOrValidKeyPairOrCommands() {
final hasPublicKeyFile = wasParsed(CommonArguments.publicKeyArg.name);
final hasPrivateKeyFile = wasParsed(CommonArguments.privateKeyArg.name);
final hasPublicKeyCmd = wasParsed(CommonArguments.publicKeyCmd.name);
final hasSignCmd = wasParsed(CommonArguments.signCmd.name);
// Can't have two public key sources
if (hasPublicKeyFile && hasPublicKeyCmd) {
logger.err(
'Cannot specify both --${CommonArguments.publicKeyArg.name} and '
'--${CommonArguments.publicKeyCmd.name}.',
);
throw ProcessExit(ExitCode.usage.code);
}
// Can't have two signing methods
if (hasPrivateKeyFile && hasSignCmd) {
logger.err(
'Cannot specify both --${CommonArguments.privateKeyArg.name} and '
'--${CommonArguments.signCmd.name}.',
);
throw ProcessExit(ExitCode.usage.code);
}
// File-based signing requires both file args
if (hasPrivateKeyFile || (hasPublicKeyFile && !hasSignCmd)) {
assertAbsentOrValidKeyPair();
}
// --sign-cmd requires a public key source
if (hasSignCmd && !hasPublicKeyFile && !hasPublicKeyCmd) {
logger.err(
'--${CommonArguments.signCmd.name} requires a public key '
'(--${CommonArguments.publicKeyArg.name} or '
'--${CommonArguments.publicKeyCmd.name}).',
);
throw ProcessExit(ExitCode.usage.code);
}
// Validate the public key file exists if provided
if (hasPublicKeyFile && hasSignCmd) {
assertAbsentOrValidPublicKey();
}
}
/// Validates public key arguments for release commands.
///
/// Valid configurations:
/// - No public key (no signing)
/// - --public-key-path with valid file
/// - --public-key-cmd
///
/// Invalid: mixing --public-key-path and --public-key-cmd
void assertAbsentOrValidPublicKeyOrCmd() {
final hasFilePath = wasParsed(CommonArguments.publicKeyArg.name);
final hasCmd = wasParsed(CommonArguments.publicKeyCmd.name);
if (hasFilePath && hasCmd) {
logger.err(
'Cannot specify both --${CommonArguments.publicKeyArg.name} and '
'--${CommonArguments.publicKeyCmd.name}.',
);
throw ProcessExit(ExitCode.usage.code);
}
if (hasFilePath) {
assertAbsentOrValidPublicKey();
}
}
/// Get base64-encoded public key from either file or command.
///
/// Returns null if no public key is configured.
Future<String?> getEncodedPublicKey() async {
try {
final pem = await resolvePublicKeyPem();
if (pem == null) return null;
return codeSigner.base64PublicKeyFromPem(pem);
} on ProcessException catch (e) {
logger.err(
'Failed to run '
'--${CommonArguments.publicKeyCmd.name}: ${e.message}',
);
throw ProcessExit(ExitCode.software.code);
} on FormatException catch (e) {
logger.err(
'--${CommonArguments.publicKeyCmd.name} produced invalid output: '
'${e.message}',
);
throw ProcessExit(ExitCode.software.code);
// Malformed PEM content causes ASN1 parsing errors (RangeError, etc.)
// ignore: avoid_catching_errors
} on Error catch (e) {
// ASN1 parsing errors for malformed PEM content
logger.err(
'--${CommonArguments.publicKeyCmd.name} output is not a valid '
'public key: $e',
);
throw ProcessExit(ExitCode.software.code);
}
}
} }
/// Extension on [ArgResults] to provide file related extensions. /// Extension on [ArgResults] to provide file related extensions.
@@ -1,11 +1,24 @@
// cspell:words pubin dgst outform // cspell:words pubin dgst outform
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/code_signer.dart'; import 'package:shorebird_cli/src/code_signer.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
class MockShorebirdProcess extends Mock implements ShorebirdProcess {}
class MockShorebirdProcessResult extends Mock
implements ShorebirdProcessResult {}
class MockProcess extends Mock implements Process {}
class MockIOSink extends Mock implements IOSink {}
void main() { void main() {
group( group(
CodeSigner, CodeSigner,
@@ -119,9 +132,250 @@ void main() {
); );
}); });
}); });
group('base64PublicKeyFromPem', () {
test('output matches base64PublicKey from file', () {
final publicKeyPem = publicKeyFile.readAsStringSync();
expect(
codeSigner.base64PublicKeyFromPem(publicKeyPem),
equals(codeSigner.base64PublicKey(publicKeyFile)),
);
});
});
group('verify', () {
const message =
'6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b';
test('returns true for valid signature', () {
final signature = codeSigner.sign(
message: message,
privateKeyPemFile: privateKeyFile,
);
final publicKeyPem = publicKeyFile.readAsStringSync();
expect(
codeSigner.verify(
message: message,
signature: signature,
publicKeyPem: publicKeyPem,
),
isTrue,
);
});
test('returns false for invalid signature', () {
final publicKeyPem = publicKeyFile.readAsStringSync();
expect(
codeSigner.verify(
message: message,
signature: 'invalid-signature',
publicKeyPem: publicKeyPem,
),
isFalse,
);
});
test('returns false for wrong message', () {
final signature = codeSigner.sign(
message: message,
privateKeyPemFile: privateKeyFile,
);
final publicKeyPem = publicKeyFile.readAsStringSync();
expect(
codeSigner.verify(
message: 'wrong-message',
signature: signature,
publicKeyPem: publicKeyPem,
),
isFalse,
);
});
});
}, },
onPlatform: { onPlatform: {
'windows': const Skip('Does not have openssl installed by default'), 'windows': const Skip('Does not have openssl installed by default'),
}, },
); );
group('CodeSigner command-based signing', () {
late ShorebirdProcess shorebirdProcess;
late CodeSigner codeSigner;
setUp(() {
shorebirdProcess = MockShorebirdProcess();
codeSigner = CodeSigner();
});
group('runPublicKeyCmd', () {
test('returns trimmed stdout on success', () async {
final result = MockShorebirdProcessResult();
when(() => result.exitCode).thenReturn(0);
when(() => result.stdout).thenReturn('''
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
-----END PUBLIC KEY-----
''');
when(
() => shorebirdProcess.run(any(), any()),
).thenAnswer((_) async => result);
await runScoped(
() async {
final output = await codeSigner.runPublicKeyCmd('cat key.pem');
expect(output, contains('-----BEGIN PUBLIC KEY-----'));
expect(output, contains('-----END PUBLIC KEY-----'));
},
values: {processRef.overrideWith(() => shorebirdProcess)},
);
});
test('throws ProcessException on non-zero exit code', () async {
final result = MockShorebirdProcessResult();
when(() => result.exitCode).thenReturn(1);
when(() => result.stderr).thenReturn('command not found');
when(
() => shorebirdProcess.run(any(), any()),
).thenAnswer((_) async => result);
await runScoped(
() async {
await expectLater(
() => codeSigner.runPublicKeyCmd('invalid-command'),
throwsA(isA<ProcessException>()),
);
},
values: {processRef.overrideWith(() => shorebirdProcess)},
);
});
test('throws FormatException when output is empty', () async {
final result = MockShorebirdProcessResult();
when(() => result.exitCode).thenReturn(0);
when(() => result.stdout).thenReturn('');
when(
() => shorebirdProcess.run(any(), any()),
).thenAnswer((_) async => result);
await runScoped(
() async {
await expectLater(
() => codeSigner.runPublicKeyCmd('empty-cmd'),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains('produced no output'),
),
),
);
},
values: {processRef.overrideWith(() => shorebirdProcess)},
);
});
test('throws FormatException when output is not a PEM key', () async {
final result = MockShorebirdProcessResult();
when(() => result.exitCode).thenReturn(0);
when(() => result.stdout).thenReturn('not a pem key');
when(
() => shorebirdProcess.run(any(), any()),
).thenAnswer((_) async => result);
await runScoped(
() async {
await expectLater(
() => codeSigner.runPublicKeyCmd('echo "not a pem key"'),
throwsA(isA<FormatException>()),
);
},
values: {processRef.overrideWith(() => shorebirdProcess)},
);
});
});
group('signWithCmd', () {
late MockProcess proc;
late MockIOSink stdin;
setUp(() {
proc = MockProcess();
stdin = MockIOSink();
when(() => proc.stdin).thenReturn(stdin);
when(() => stdin.close()).thenAnswer((_) async {});
});
test('returns trimmed stdout on success', () async {
when(() => proc.stdout).thenAnswer(
(_) => Stream.value(utf8.encode('base64signature\n')),
);
when(() => proc.stderr).thenAnswer((_) => const Stream.empty());
when(() => proc.exitCode).thenAnswer((_) async => 0);
when(
() => shorebirdProcess.start(any(), any()),
).thenAnswer((_) async => proc);
await runScoped(
() async {
final signature = await codeSigner.signWithCmd(
data: 'hash-to-sign',
command: 'sign-script.sh',
);
expect(signature, equals('base64signature'));
},
values: {processRef.overrideWith(() => shorebirdProcess)},
);
});
test('throws ProcessException on non-zero exit code', () async {
when(() => proc.stdout).thenAnswer((_) => const Stream.empty());
when(() => proc.stderr).thenAnswer(
(_) => Stream.value(utf8.encode('error')),
);
when(() => proc.exitCode).thenAnswer((_) async => 1);
when(
() => shorebirdProcess.start(any(), any()),
).thenAnswer((_) async => proc);
await runScoped(
() async {
await expectLater(
() => codeSigner.signWithCmd(
data: 'hash-to-sign',
command: 'failing-script.sh',
),
throwsA(isA<ProcessException>()),
);
},
values: {processRef.overrideWith(() => shorebirdProcess)},
);
});
test('throws FormatException when output is empty', () async {
when(() => proc.stdout).thenAnswer(
(_) => Stream.value(utf8.encode(' \n ')),
);
when(() => proc.stderr).thenAnswer((_) => const Stream.empty());
when(() => proc.exitCode).thenAnswer((_) async => 0);
when(
() => shorebirdProcess.start(any(), any()),
).thenAnswer((_) async => proc);
await runScoped(
() async {
await expectLater(
() => codeSigner.signWithCmd(
data: 'hash-to-sign',
command: 'empty-output-script.sh',
),
throwsA(isA<FormatException>()),
);
},
values: {processRef.overrideWith(() => shorebirdProcess)},
);
});
});
});
} }
@@ -551,16 +551,18 @@ void main() {
group('when a private key is provided', () { group('when a private key is provided', () {
setUp(() { setUp(() {
final privateKey = File( final tempDir = Directory.systemTemp.createTempSync();
p.join( final privateKey = File(p.join(tempDir.path, 'test-private.pem'))
Directory.systemTemp.createTempSync().path, ..createSync();
'test-private.pem', final publicKey = File(p.join(tempDir.path, 'test-public.pem'))
), ..writeAsStringSync('public-key-pem');
)..createSync();
when( when(
() => argResults[CommonArguments.privateKeyArg.name], () => argResults[CommonArguments.privateKeyArg.name],
).thenReturn(privateKey.path); ).thenReturn(privateKey.path);
when(
() => argResults[CommonArguments.publicKeyArg.name],
).thenReturn(publicKey.path);
when( when(
() => codeSigner.sign( () => codeSigner.sign(
@@ -571,6 +573,13 @@ void main() {
final message = invocation.namedArguments[#message] as String; final message = invocation.namedArguments[#message] as String;
return '$message-signature'; return '$message-signature';
}); });
when(
() => codeSigner.verify(
message: any(named: 'message'),
signature: any(named: 'signature'),
publicKeyPem: any(named: 'publicKeyPem'),
),
).thenReturn(true);
}); });
test( test(
@@ -594,16 +594,18 @@ Looked in:
group('when a private key is provided', () { group('when a private key is provided', () {
setUp(() { setUp(() {
final privateKey = File( final tempDir = Directory.systemTemp.createTempSync();
p.join( final privateKey = File(p.join(tempDir.path, 'test-private.pem'))
Directory.systemTemp.createTempSync().path, ..createSync();
'test-private.pem', final publicKey = File(p.join(tempDir.path, 'test-public.pem'))
), ..writeAsStringSync('public-key-pem');
)..createSync();
when( when(
() => argResults[CommonArguments.privateKeyArg.name], () => argResults[CommonArguments.privateKeyArg.name],
).thenReturn(privateKey.path); ).thenReturn(privateKey.path);
when(
() => argResults[CommonArguments.publicKeyArg.name],
).thenReturn(publicKey.path);
when( when(
() => codeSigner.sign( () => codeSigner.sign(
@@ -614,6 +616,13 @@ Looked in:
final message = invocation.namedArguments[#message] as String; final message = invocation.namedArguments[#message] as String;
return '$message-signature'; return '$message-signature';
}); });
when(
() => codeSigner.verify(
message: any(named: 'message'),
signature: any(named: 'signature'),
publicKeyPem: any(named: 'publicKeyPem'),
),
).thenReturn(true);
}); });
test( test(
@@ -872,16 +872,19 @@ void main() {
group('when code signing the patch', () { group('when code signing the patch', () {
setUp(() { setUp(() {
final tempDir = Directory.systemTemp.createTempSync();
final privateKey = File( final privateKey = File(
p.join( p.join(tempDir.path, 'test-private.pem'),
Directory.systemTemp.createTempSync().path,
'test-private.pem',
),
)..createSync(); )..createSync();
final publicKey = File(p.join(tempDir.path, 'test-public.pem'))
..writeAsStringSync('public-key-pem');
when( when(
() => argResults[CommonArguments.privateKeyArg.name], () => argResults[CommonArguments.privateKeyArg.name],
).thenReturn(privateKey.path); ).thenReturn(privateKey.path);
when(
() => argResults[CommonArguments.publicKeyArg.name],
).thenReturn(publicKey.path);
when( when(
() => codeSigner.sign( () => codeSigner.sign(
@@ -892,6 +895,13 @@ void main() {
final message = invocation.namedArguments[#message] as String; final message = invocation.namedArguments[#message] as String;
return '$message-signature'; return '$message-signature';
}); });
when(
() => codeSigner.verify(
message: any(named: 'message'),
signature: any(named: 'signature'),
publicKeyPem: any(named: 'publicKeyPem'),
),
).thenReturn(true);
}); });
test( test(
@@ -1169,16 +1169,19 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
group('when code signing the patch', () { group('when code signing the patch', () {
setUp(() { setUp(() {
final tempDir = Directory.systemTemp.createTempSync();
final privateKey = File( final privateKey = File(
p.join( p.join(tempDir.path, 'test-private.pem'),
Directory.systemTemp.createTempSync().path,
'test-private.pem',
),
)..createSync(); )..createSync();
final publicKey = File(p.join(tempDir.path, 'test-public.pem'))
..writeAsStringSync('public-key-pem');
when( when(
() => argResults[CommonArguments.privateKeyArg.name], () => argResults[CommonArguments.privateKeyArg.name],
).thenReturn(privateKey.path); ).thenReturn(privateKey.path);
when(
() => argResults[CommonArguments.publicKeyArg.name],
).thenReturn(publicKey.path);
when( when(
() => codeSigner.sign( () => codeSigner.sign(
@@ -1189,6 +1192,13 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
final message = invocation.namedArguments[#message] as String; final message = invocation.namedArguments[#message] as String;
return '$message-signature'; return '$message-signature';
}); });
when(
() => codeSigner.verify(
message: any(named: 'message'),
signature: any(named: 'signature'),
publicKeyPem: any(named: 'publicKeyPem'),
),
).thenReturn(true);
}); });
test( test(
@@ -366,6 +366,12 @@ void main() {
group('when signing keys are provided', () { group('when signing keys are provided', () {
setUp(() { setUp(() {
final publicKeyFile = File(
p.join(
Directory.systemTemp.createTempSync().path,
'public-key.pem',
),
)..createSync();
when( when(
() => artifactManager.createDiff( () => artifactManager.createDiff(
releaseArtifactPath: any(named: 'releaseArtifactPath'), releaseArtifactPath: any(named: 'releaseArtifactPath'),
@@ -374,7 +380,7 @@ void main() {
).thenAnswer((_) async => diffFile.path); ).thenAnswer((_) async => diffFile.path);
when( when(
() => argResults[CommonArguments.publicKeyArg.name], () => argResults[CommonArguments.publicKeyArg.name],
).thenReturn('public-key.pem'); ).thenReturn(publicKeyFile.path);
when( when(
() => argResults[CommonArguments.privateKeyArg.name], () => argResults[CommonArguments.privateKeyArg.name],
).thenReturn('private-key.pem'); ).thenReturn('private-key.pem');
@@ -384,6 +390,13 @@ void main() {
privateKeyPemFile: any(named: 'privateKeyPemFile'), privateKeyPemFile: any(named: 'privateKeyPemFile'),
), ),
).thenReturn('signature'); ).thenReturn('signature');
when(
() => codeSigner.verify(
message: any(named: 'message'),
signature: any(named: 'signature'),
publicKeyPem: any(named: 'publicKeyPem'),
),
).thenReturn(true);
}); });
test('signs patch', () async { test('signs patch', () async {
@@ -952,6 +952,13 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
when( when(
() => argResults[CommonArguments.privateKeyArg.name], () => argResults[CommonArguments.privateKeyArg.name],
).thenReturn(createTempFile('private.pem').path); ).thenReturn(createTempFile('private.pem').path);
when(
() => argResults[CommonArguments.publicKeyArg.name],
).thenReturn(
(createTempFile(
'public.pem',
)..writeAsStringSync('public-key-pem')).path,
);
when( when(
() => codeSigner.sign( () => codeSigner.sign(
@@ -959,6 +966,13 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
privateKeyPemFile: any(named: 'privateKeyPemFile'), privateKeyPemFile: any(named: 'privateKeyPemFile'),
), ),
).thenReturn('my-signature'); ).thenReturn('my-signature');
when(
() => codeSigner.verify(
message: any(named: 'message'),
signature: any(named: 'signature'),
publicKeyPem: any(named: 'publicKeyPem'),
),
).thenReturn(true);
}); });
test('returns artifact bundles with non-null hash signature', () async { test('returns artifact bundles with non-null hash signature', () async {
@@ -194,6 +194,12 @@ void main() {
when( when(
() => argResults.wasParsed(CommonArguments.publicKeyArg.name), () => argResults.wasParsed(CommonArguments.publicKeyArg.name),
).thenReturn(false); ).thenReturn(false);
when(
() => argResults.wasParsed(CommonArguments.publicKeyCmd.name),
).thenReturn(false);
when(
() => argResults.wasParsed(CommonArguments.signCmd.name),
).thenReturn(false);
when(aotTools.isLinkDebugInfoSupported).thenAnswer((_) async => true); when(aotTools.isLinkDebugInfoSupported).thenAnswer((_) async => true);
@@ -2,24 +2,31 @@ import 'dart:io';
import 'package:args/args.dart'; import 'package:args/args.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
import 'package:scoped_deps/scoped_deps.dart'; import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/code_signer.dart';
import 'package:shorebird_cli/src/commands/commands.dart'; import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/common_arguments.dart';
import 'package:shorebird_cli/src/deployment_track.dart'; import 'package:shorebird_cli/src/deployment_track.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart'; import 'package:shorebird_cli/src/patch_diff_checker.dart';
import 'package:shorebird_cli/src/platform/platform.dart'; import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/release_type.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_client/shorebird_code_push_client.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import '../../mocks.dart'; import '../../mocks.dart';
class FakeFile extends Fake implements File {}
void main() { void main() {
group(Patcher, () { group(Patcher, () {
setUpAll(() { setUpAll(() {
registerFallbackValue(ReleasePlatform.android); registerFallbackValue(ReleasePlatform.android);
registerFallbackValue(DeploymentTrack.stable); registerFallbackValue(DeploymentTrack.stable);
registerFallbackValue(FakeFile());
}); });
group('linkPercentage', () { group('linkPercentage', () {
@@ -236,6 +243,358 @@ void main() {
).called(1); ).called(1);
}); });
}); });
group('signHash', () {
final cryptoFixturesBasePath = p.join('test', 'fixtures', 'crypto');
final privateKeyFile = File(
p.join(cryptoFixturesBasePath, 'private.pem'),
);
late ArgParser argParser;
late ArgResults argResults;
late CodeSigner codeSigner;
late ShorebirdLogger logger;
late File publicKeyTempFile;
setUp(() {
argParser = ArgParser()
..addOption(CommonArguments.publicKeyArg.name)
..addOption(CommonArguments.privateKeyArg.name)
..addOption(CommonArguments.publicKeyCmd.name)
..addOption(CommonArguments.signCmd.name);
codeSigner = MockCodeSigner();
logger = MockShorebirdLogger();
publicKeyTempFile = File(
p.join(
Directory.systemTemp.createTempSync().path,
'public.pem',
),
)..writeAsStringSync('fake-public-key-pem');
});
test('returns null when no signing is configured', () async {
argResults = argParser.parse([]);
final patcher = _TestPatcher(
argParser: argParser,
argResults: argResults,
flavor: null,
target: null,
);
await runScoped(
() async {
final result = await patcher.signHash('test-hash');
expect(result, isNull);
},
values: {codeSignerRef.overrideWith(() => codeSigner)},
);
});
test('returns signature from file-based signing', () async {
argResults = argParser.parse([
'--${CommonArguments.publicKeyArg.name}=${publicKeyTempFile.path}',
'--${CommonArguments.privateKeyArg.name}=${privateKeyFile.path}',
]);
when(
() => codeSigner.sign(
message: any(named: 'message'),
privateKeyPemFile: any(named: 'privateKeyPemFile'),
),
).thenReturn('file-signature');
when(
() => codeSigner.verify(
message: any(named: 'message'),
signature: any(named: 'signature'),
publicKeyPem: any(named: 'publicKeyPem'),
),
).thenReturn(true);
final patcher = _TestPatcher(
argParser: argParser,
argResults: argResults,
flavor: null,
target: null,
);
await runScoped(
() async {
final result = await patcher.signHash('test-hash');
expect(result, equals('file-signature'));
},
values: {codeSignerRef.overrideWith(() => codeSigner)},
);
});
test(
'throws ProcessExit when signer present but no public key',
() async {
argResults = argParser.parse([
'--${CommonArguments.privateKeyArg.name}=${privateKeyFile.path}',
]);
when(
() => codeSigner.sign(
message: any(named: 'message'),
privateKeyPemFile: any(named: 'privateKeyPemFile'),
),
).thenReturn('file-signature');
final patcher = _TestPatcher(
argParser: argParser,
argResults: argResults,
flavor: null,
target: null,
);
await runScoped(
() async {
await expectLater(
() => patcher.signHash('test-hash'),
throwsA(isA<ProcessExit>()),
);
verify(
() => logger.err(
any(that: contains('public key is required')),
),
).called(1);
},
values: {
codeSignerRef.overrideWith(() => codeSigner),
loggerRef.overrideWith(() => logger),
},
);
},
);
test('returns signature from command-based signing when valid', () async {
argResults = argParser.parse([
'--${CommonArguments.publicKeyCmd.name}=get-key-cmd',
'--${CommonArguments.signCmd.name}=sign-cmd',
]);
when(
() => codeSigner.signWithCmd(
data: any(named: 'data'),
command: any(named: 'command'),
),
).thenAnswer((_) async => 'cmd-signature');
when(
() => codeSigner.runPublicKeyCmd(any()),
).thenAnswer((_) async => 'pem-public-key');
when(
() => codeSigner.verify(
message: any(named: 'message'),
signature: any(named: 'signature'),
publicKeyPem: any(named: 'publicKeyPem'),
),
).thenReturn(true);
final patcher = _TestPatcher(
argParser: argParser,
argResults: argResults,
flavor: null,
target: null,
);
await runScoped(
() async {
final result = await patcher.signHash('test-hash');
expect(result, equals('cmd-signature'));
verify(
() => codeSigner.signWithCmd(
data: 'test-hash',
command: 'sign-cmd',
),
).called(1);
verify(() => codeSigner.runPublicKeyCmd('get-key-cmd')).called(1);
verify(
() => codeSigner.verify(
message: 'test-hash',
signature: 'cmd-signature',
publicKeyPem: 'pem-public-key',
),
).called(1);
},
values: {codeSignerRef.overrideWith(() => codeSigner)},
);
});
test('throws ProcessExit when sign-cmd fails', () async {
argResults = argParser.parse([
'--${CommonArguments.publicKeyCmd.name}=get-key-cmd',
'--${CommonArguments.signCmd.name}=bad-cmd',
]);
when(
() => codeSigner.signWithCmd(
data: any(named: 'data'),
command: any(named: 'command'),
),
).thenThrow(
const ProcessException('bad-cmd', [], 'command not found', 127),
);
final patcher = _TestPatcher(
argParser: argParser,
argResults: argResults,
flavor: null,
target: null,
);
await runScoped(
() async {
await expectLater(
() => patcher.signHash('test-hash'),
throwsA(isA<ProcessExit>()),
);
verify(
() => logger.err(any(that: contains('--sign-cmd'))),
).called(1);
},
values: {
codeSignerRef.overrideWith(() => codeSigner),
loggerRef.overrideWith(() => logger),
},
);
});
test('throws ProcessExit when public-key-cmd fails', () async {
argResults = argParser.parse([
'--${CommonArguments.publicKeyCmd.name}=bad-key-cmd',
'--${CommonArguments.signCmd.name}=sign-cmd',
]);
when(
() => codeSigner.signWithCmd(
data: any(named: 'data'),
command: any(named: 'command'),
),
).thenAnswer((_) async => 'signature');
when(
() => codeSigner.runPublicKeyCmd(any()),
).thenThrow(
const ProcessException(
'bad-key-cmd',
[],
'command not found',
127,
),
);
final patcher = _TestPatcher(
argParser: argParser,
argResults: argResults,
flavor: null,
target: null,
);
await runScoped(
() async {
await expectLater(
() => patcher.signHash('test-hash'),
throwsA(isA<ProcessExit>()),
);
verify(
() => logger.err(any(that: contains('--public-key-cmd'))),
).called(1);
},
values: {
codeSignerRef.overrideWith(() => codeSigner),
loggerRef.overrideWith(() => logger),
},
);
});
test('supports mixed signing (public key file + sign cmd)', () async {
argResults = argParser.parse([
'--${CommonArguments.publicKeyArg.name}=${publicKeyTempFile.path}',
'--${CommonArguments.signCmd.name}=sign-cmd',
]);
when(
() => codeSigner.signWithCmd(
data: any(named: 'data'),
command: any(named: 'command'),
),
).thenAnswer((_) async => 'cmd-signature');
when(
() => codeSigner.verify(
message: any(named: 'message'),
signature: any(named: 'signature'),
publicKeyPem: any(named: 'publicKeyPem'),
),
).thenReturn(true);
final patcher = _TestPatcher(
argParser: argParser,
argResults: argResults,
flavor: null,
target: null,
);
await runScoped(
() async {
final result = await patcher.signHash('test-hash');
expect(result, equals('cmd-signature'));
verifyNever(() => codeSigner.runPublicKeyCmd(any()));
verify(
() => codeSigner.verify(
message: 'test-hash',
signature: 'cmd-signature',
publicKeyPem: any(named: 'publicKeyPem'),
),
).called(1);
},
values: {codeSignerRef.overrideWith(() => codeSigner)},
);
});
test('throws ProcessExit when signature verification fails', () async {
argResults = argParser.parse([
'--${CommonArguments.publicKeyCmd.name}=get-key-cmd',
'--${CommonArguments.signCmd.name}=sign-cmd',
]);
when(
() => codeSigner.signWithCmd(
data: any(named: 'data'),
command: any(named: 'command'),
),
).thenAnswer((_) async => 'bad-signature');
when(
() => codeSigner.runPublicKeyCmd(any()),
).thenAnswer((_) async => 'pem-public-key');
when(
() => codeSigner.verify(
message: any(named: 'message'),
signature: any(named: 'signature'),
publicKeyPem: any(named: 'publicKeyPem'),
),
).thenReturn(false);
final patcher = _TestPatcher(
argParser: argParser,
argResults: argResults,
flavor: null,
target: null,
);
await runScoped(
() async {
await expectLater(
() => patcher.signHash('test-hash'),
throwsA(isA<ProcessExit>()),
);
},
values: {
codeSignerRef.overrideWith(() => codeSigner),
loggerRef.overrideWith(() => logger),
},
);
});
});
}); });
} }
@@ -467,6 +467,12 @@ void main() {
group('when signing keys are provided', () { group('when signing keys are provided', () {
setUp(() { setUp(() {
final publicKeyFile = File(
p.join(
Directory.systemTemp.createTempSync().path,
'public-key.pem',
),
)..createSync();
when( when(
() => artifactManager.createDiff( () => artifactManager.createDiff(
releaseArtifactPath: any(named: 'releaseArtifactPath'), releaseArtifactPath: any(named: 'releaseArtifactPath'),
@@ -475,7 +481,7 @@ void main() {
).thenAnswer((_) async => diffFile.path); ).thenAnswer((_) async => diffFile.path);
when( when(
() => argResults[CommonArguments.publicKeyArg.name], () => argResults[CommonArguments.publicKeyArg.name],
).thenReturn('public-key.pem'); ).thenReturn(publicKeyFile.path);
when( when(
() => argResults[CommonArguments.privateKeyArg.name], () => argResults[CommonArguments.privateKeyArg.name],
).thenReturn('private-key.pem'); ).thenReturn('private-key.pem');
@@ -485,6 +491,13 @@ void main() {
privateKeyPemFile: any(named: 'privateKeyPemFile'), privateKeyPemFile: any(named: 'privateKeyPemFile'),
), ),
).thenReturn('signature'); ).thenReturn('signature');
when(
() => codeSigner.verify(
message: any(named: 'message'),
signature: any(named: 'signature'),
publicKeyPem: any(named: 'publicKeyPem'),
),
).thenReturn(true);
}); });
test('signs patch', () async { test('signs patch', () async {
@@ -326,6 +326,9 @@ void main() {
when( when(
() => argResults[CommonArguments.publicKeyArg.name], () => argResults[CommonArguments.publicKeyArg.name],
).thenReturn(patchSigningPublicKeyFile.path); ).thenReturn(patchSigningPublicKeyFile.path);
when(
() => argResults.wasParsed(CommonArguments.publicKeyArg.name),
).thenReturn(true);
when( when(
() => artifactBuilder.buildAar( () => artifactBuilder.buildAar(
@@ -337,7 +340,7 @@ void main() {
).thenAnswer((_) async => File('')); ).thenAnswer((_) async => File(''));
when( when(
() => codeSigner.base64PublicKey(any()), () => codeSigner.base64PublicKeyFromPem(any()),
).thenReturn(base64PublicKey); ).thenReturn(base64PublicKey);
}); });
@@ -357,6 +360,57 @@ void main() {
}, },
); );
}); });
group('when a public-key-cmd is provided', () {
const base64PublicKey = 'base64PublicKeyFromCmd';
setUp(() {
when(
() => argResults[CommonArguments.publicKeyCmd.name],
).thenReturn('get-key-cmd');
when(
() => argResults.wasParsed(CommonArguments.publicKeyCmd.name),
).thenReturn(true);
when(
() => artifactBuilder.buildAar(
buildNumber: any(named: 'buildNumber'),
targetPlatforms: any(named: 'targetPlatforms'),
args: any(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).thenAnswer((_) async => File(''));
when(
() => codeSigner.runPublicKeyCmd(any()),
).thenAnswer((_) async => 'pem-public-key');
when(
() => codeSigner.base64PublicKeyFromPem(any()),
).thenReturn(base64PublicKey);
});
test(
'runs public key cmd and forwards encoded key to buildAar',
() async {
await runWithOverrides(() => aarReleaser.buildReleaseArtifacts());
verify(
() => codeSigner.runPublicKeyCmd('get-key-cmd'),
).called(1);
verify(
() => codeSigner.base64PublicKeyFromPem('pem-public-key'),
).called(1);
verify(
() => artifactBuilder.buildAar(
buildNumber: buildNumber,
targetPlatforms: any(named: 'targetPlatforms'),
args: any(named: 'args'),
base64PublicKey: base64PublicKey,
),
).called(1);
},
);
});
}); });
}); });
@@ -417,6 +417,9 @@ To change the version of this release, change your app's version in your pubspec
when( when(
() => argResults[CommonArguments.publicKeyArg.name], () => argResults[CommonArguments.publicKeyArg.name],
).thenReturn(patchSigningPublicKeyFile.path); ).thenReturn(patchSigningPublicKeyFile.path);
when(
() => argResults.wasParsed(CommonArguments.publicKeyArg.name),
).thenReturn(true);
when( when(
() => artifactBuilder.buildAppBundle( () => artifactBuilder.buildAppBundle(
@@ -438,7 +441,7 @@ To change the version of this release, change your app's version in your pubspec
).thenAnswer((_) async => File('')); ).thenAnswer((_) async => File(''));
when( when(
() => codeSigner.base64PublicKey(any()), () => codeSigner.base64PublicKeyFromPem(any()),
).thenReturn(base64PublicKey); ).thenReturn(base64PublicKey);
}); });
@@ -496,6 +499,61 @@ To change the version of this release, change your app's version in your pubspec
); );
}); });
}); });
group('when a public-key-cmd is provided', () {
const base64PublicKey = 'base64PublicKeyFromCmd';
setUp(() {
when(
() => argResults[CommonArguments.publicKeyCmd.name],
).thenReturn('get-key-cmd');
when(
() => argResults.wasParsed(CommonArguments.publicKeyCmd.name),
).thenReturn(true);
when(
() => artifactBuilder.buildAppBundle(
flavor: any(named: 'flavor'),
target: any(named: 'target'),
targetPlatforms: any(named: 'targetPlatforms'),
args: any(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).thenAnswer((_) async => aabFile);
when(
() => codeSigner.runPublicKeyCmd(any()),
).thenAnswer((_) async => 'pem-public-key');
when(
() => codeSigner.base64PublicKeyFromPem(any()),
).thenReturn(base64PublicKey);
});
test(
'runs public key cmd and forwards encoded key to buildAab',
() async {
await runWithOverrides(
() => androidReleaser.buildReleaseArtifacts(),
);
verify(
() => codeSigner.runPublicKeyCmd('get-key-cmd'),
).called(1);
verify(
() => codeSigner.base64PublicKeyFromPem('pem-public-key'),
).called(1);
verify(
() => artifactBuilder.buildAppBundle(
flavor: any(named: 'flavor'),
target: any(named: 'target'),
targetPlatforms: any(named: 'targetPlatforms'),
args: any(named: 'args'),
base64PublicKey: base64PublicKey,
),
).called(1);
},
);
});
}); });
group('getReleaseVersion', () { group('getReleaseVersion', () {
@@ -293,6 +293,9 @@ void main() {
when( when(
() => argResults[CommonArguments.publicKeyArg.name], () => argResults[CommonArguments.publicKeyArg.name],
).thenReturn(patchSigningPublicKeyFile.path); ).thenReturn(patchSigningPublicKeyFile.path);
when(
() => argResults.wasParsed(CommonArguments.publicKeyArg.name),
).thenReturn(true);
when( when(
() => artifactBuilder.buildIosFramework( () => artifactBuilder.buildIosFramework(
@@ -304,7 +307,7 @@ void main() {
AppleBuildResult(kernelFile: File('/path/to/app.dill')), AppleBuildResult(kernelFile: File('/path/to/app.dill')),
); );
when( when(
() => codeSigner.base64PublicKey(any()), () => codeSigner.base64PublicKeyFromPem(any()),
).thenReturn(base64PublicKey); ).thenReturn(base64PublicKey);
}); });
@@ -326,6 +329,59 @@ void main() {
); );
}); });
group('when a public-key-cmd is provided', () {
const base64PublicKey = 'base64PublicKeyFromCmd';
setUp(() {
when(
() => argResults[CommonArguments.publicKeyCmd.name],
).thenReturn('get-key-cmd');
when(
() => argResults.wasParsed(CommonArguments.publicKeyCmd.name),
).thenReturn(true);
when(
() => artifactBuilder.buildIosFramework(
args: any(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).thenAnswer(
(_) async =>
AppleBuildResult(kernelFile: File('/path/to/app.dill')),
);
when(
() => codeSigner.runPublicKeyCmd(any()),
).thenAnswer((_) async => 'pem-public-key');
when(
() => codeSigner.base64PublicKeyFromPem(any()),
).thenReturn(base64PublicKey);
});
test(
'runs public key cmd and forwards encoded key to '
'buildIosFramework',
() async {
await runWithOverrides(
() => iosFrameworkReleaser.buildReleaseArtifacts(),
);
verify(
() => codeSigner.runPublicKeyCmd('get-key-cmd'),
).called(1);
verify(
() => codeSigner.base64PublicKeyFromPem('pem-public-key'),
).called(1);
verify(
() => artifactBuilder.buildIosFramework(
args: any(named: 'args'),
base64PublicKey: base64PublicKey,
),
).called(1);
},
);
});
group('when stale build/ios/shorebird directory exists', () { group('when stale build/ios/shorebird directory exists', () {
late Directory shorebirdSupplementDir; late Directory shorebirdSupplementDir;
@@ -310,7 +310,7 @@ To change the version of this release, change your app's version in your pubspec
).thenReturn(xcarchiveDirectory); ).thenReturn(xcarchiveDirectory);
when( when(
() => codeSigner.base64PublicKey(any()), () => codeSigner.base64PublicKeyFromPem(any()),
).thenReturn(base64PublicKey); ).thenReturn(base64PublicKey);
when( when(
@@ -332,6 +332,9 @@ To change the version of this release, change your app's version in your pubspec
when( when(
() => argResults[CommonArguments.publicKeyArg.name], () => argResults[CommonArguments.publicKeyArg.name],
).thenReturn(patchSigningPublicKeyFile.path); ).thenReturn(patchSigningPublicKeyFile.path);
when(
() => argResults.wasParsed(CommonArguments.publicKeyArg.name),
).thenReturn(true);
when( when(
() => artifactBuilder.buildIpa( () => artifactBuilder.buildIpa(
@@ -365,6 +368,60 @@ To change the version of this release, change your app's version in your pubspec
); );
}); });
group('when a public-key-cmd is provided', () {
setUp(() {
when(
() => argResults[CommonArguments.publicKeyCmd.name],
).thenReturn('get-key-cmd');
when(
() => argResults.wasParsed(CommonArguments.publicKeyCmd.name),
).thenReturn(true);
when(
() => artifactBuilder.buildIpa(
codesign: any(named: 'codesign'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
args: any(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).thenAnswer(
(_) async =>
AppleBuildResult(kernelFile: File('/path/to/app.dill')),
);
when(
() => codeSigner.runPublicKeyCmd(any()),
).thenAnswer((_) async => 'pem-public-key');
when(
() => codeSigner.base64PublicKeyFromPem(any()),
).thenReturn('base64PublicKeyFromCmd');
});
test(
'runs public key cmd and forwards encoded key to buildIpa',
() async {
await runWithOverrides(() => iosReleaser.buildReleaseArtifacts());
verify(
() => codeSigner.runPublicKeyCmd('get-key-cmd'),
).called(1);
verify(
() => codeSigner.base64PublicKeyFromPem('pem-public-key'),
).called(1);
verify(
() => artifactBuilder.buildIpa(
codesign: any(named: 'codesign'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
args: any(named: 'args'),
base64PublicKey: 'base64PublicKeyFromCmd',
),
).called(1);
},
);
});
group('when not codesigning', () { group('when not codesigning', () {
setUp(() { setUp(() {
when(() => argResults['codesign']).thenReturn(false); when(() => argResults['codesign']).thenReturn(false);
@@ -296,14 +296,20 @@ To change the version of this release, change your app's version in your pubspec
group('when public key is passed as an arg', () { group('when public key is passed as an arg', () {
setUp(() { setUp(() {
final publicKeyFile = File(
p.join(
Directory.systemTemp.createTempSync().path,
'public-key.pem',
),
)..createSync(recursive: true);
when( when(
() => argResults.wasParsed(CommonArguments.publicKeyArg.name), () => argResults.wasParsed(CommonArguments.publicKeyArg.name),
).thenReturn(true); ).thenReturn(true);
when( when(
() => argResults[CommonArguments.publicKeyArg.name], () => argResults[CommonArguments.publicKeyArg.name],
).thenReturn('public_key'); ).thenReturn(publicKeyFile.path);
when( when(
() => codeSigner.base64PublicKey(any()), () => codeSigner.base64PublicKeyFromPem(any()),
).thenReturn('encoded_public_key'); ).thenReturn('encoded_public_key');
}); });
@@ -318,6 +324,38 @@ To change the version of this release, change your app's version in your pubspec
).called(1); ).called(1);
}); });
}); });
group('when a public-key-cmd is provided', () {
setUp(() {
when(
() => argResults[CommonArguments.publicKeyCmd.name],
).thenReturn('get-key-cmd');
when(
() => argResults.wasParsed(CommonArguments.publicKeyCmd.name),
).thenReturn(true);
when(
() => codeSigner.runPublicKeyCmd(any()),
).thenAnswer((_) async => 'pem-public-key');
when(
() => codeSigner.base64PublicKeyFromPem(any()),
).thenReturn('encoded_public_key_from_cmd');
});
test('passes public key to buildLinuxApp', () async {
await runWithOverrides(releaser.buildReleaseArtifacts);
verify(
() => codeSigner.runPublicKeyCmd('get-key-cmd'),
).called(1);
verify(
() => artifactBuilder.buildLinuxApp(
base64PublicKey: 'encoded_public_key_from_cmd',
target: any(named: 'target'),
args: any(named: 'args'),
),
).called(1);
});
});
}); });
group('getReleaseVersion', () { group('getReleaseVersion', () {
@@ -11,7 +11,9 @@ import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart'; import 'package:shorebird_cli/src/artifact_builder/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/code_signer.dart';
import 'package:shorebird_cli/src/commands/release/release.dart'; import 'package:shorebird_cli/src/commands/release/release.dart';
import 'package:shorebird_cli/src/common_arguments.dart';
import 'package:shorebird_cli/src/config/config.dart'; import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/xcodebuild.dart'; import 'package:shorebird_cli/src/executables/xcodebuild.dart';
@@ -35,6 +37,7 @@ void main() {
late ArtifactBuilder artifactBuilder; late ArtifactBuilder artifactBuilder;
late ArtifactManager artifactManager; late ArtifactManager artifactManager;
late CodePushClientWrapper codePushClientWrapper; late CodePushClientWrapper codePushClientWrapper;
late CodeSigner codeSigner;
late Directory projectRoot; late Directory projectRoot;
late Doctor doctor; late Doctor doctor;
late FlavorValidator flavorValidator; late FlavorValidator flavorValidator;
@@ -54,6 +57,7 @@ void main() {
artifactBuilderRef.overrideWith(() => artifactBuilder), artifactBuilderRef.overrideWith(() => artifactBuilder),
artifactManagerRef.overrideWith(() => artifactManager), artifactManagerRef.overrideWith(() => artifactManager),
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
codeSignerRef.overrideWith(() => codeSigner),
doctorRef.overrideWith(() => doctor), doctorRef.overrideWith(() => doctor),
loggerRef.overrideWith(() => logger), loggerRef.overrideWith(() => logger),
shorebirdEnvRef.overrideWith(() => shorebirdEnv), shorebirdEnvRef.overrideWith(() => shorebirdEnv),
@@ -69,6 +73,7 @@ void main() {
artifactBuilder = MockArtifactBuilder(); artifactBuilder = MockArtifactBuilder();
artifactManager = MockArtifactManager(); artifactManager = MockArtifactManager();
codePushClientWrapper = MockCodePushClientWrapper(); codePushClientWrapper = MockCodePushClientWrapper();
codeSigner = MockCodeSigner();
doctor = MockDoctor(); doctor = MockDoctor();
flavorValidator = MockFlavorValidator(); flavorValidator = MockFlavorValidator();
projectRoot = Directory.systemTemp.createTempSync(); projectRoot = Directory.systemTemp.createTempSync();
@@ -269,6 +274,7 @@ To change the version of this release, change your app's version in your pubspec
flavor: any(named: 'flavor'), flavor: any(named: 'flavor'),
target: any(named: 'target'), target: any(named: 'target'),
args: any(named: 'args'), args: any(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
), ),
).thenAnswer( ).thenAnswer(
(_) async => AppleBuildResult(kernelFile: File('/path/to/app.dill')), (_) async => AppleBuildResult(kernelFile: File('/path/to/app.dill')),
@@ -356,6 +362,41 @@ To change the version of this release, change your app's version in your pubspec
}); });
}); });
group('when a public-key-cmd is provided', () {
setUp(() {
when(
() => argResults[CommonArguments.publicKeyCmd.name],
).thenReturn('get-key-cmd');
when(
() => argResults.wasParsed(CommonArguments.publicKeyCmd.name),
).thenReturn(true);
when(
() => codeSigner.runPublicKeyCmd(any()),
).thenAnswer((_) async => 'pem-public-key');
when(
() => codeSigner.base64PublicKeyFromPem(any()),
).thenReturn('base64PublicKeyFromCmd');
});
test('runs public key cmd and forwards encoded key', () async {
await runWithOverrides(releaser.buildReleaseArtifacts);
verify(
() => codeSigner.runPublicKeyCmd('get-key-cmd'),
).called(1);
verify(
() => codeSigner.base64PublicKeyFromPem('pem-public-key'),
).called(1);
verify(
() => artifactBuilder.buildMacos(
base64PublicKey: 'base64PublicKeyFromCmd',
args: any(named: 'args'),
),
).called(1);
});
});
test('verifies artifacts exist and returns app path', () async { test('verifies artifacts exist and returns app path', () async {
expect( expect(
await runWithOverrides(releaser.buildReleaseArtifacts), await runWithOverrides(releaser.buildReleaseArtifacts),
@@ -107,6 +107,9 @@ void main() {
when(() => argResults['platforms']).thenReturn(['android']); when(() => argResults['platforms']).thenReturn(['android']);
when(() => argResults['flutter-version']).thenReturn('latest'); when(() => argResults['flutter-version']).thenReturn('latest');
when(() => argResults.wasParsed(any())).thenReturn(true); when(() => argResults.wasParsed(any())).thenReturn(true);
when(
() => argResults.wasParsed(CommonArguments.publicKeyCmd.name),
).thenReturn(false);
when(cache.updateAll).thenAnswer((_) async => {}); when(cache.updateAll).thenAnswer((_) async => {});
@@ -699,7 +702,9 @@ $exception'''),
verify( verify(
() => logger.warn( () => logger.warn(
'patch_verification is set in shorebird.yaml but ' 'patch_verification is set in shorebird.yaml but '
'--${CommonArguments.publicKeyArg.name} was not provided.\n' 'no public key was provided '
'(--${CommonArguments.publicKeyArg.name} '
'or --${CommonArguments.publicKeyCmd.name}).\n'
'patch_verification configuration will have no effect.', 'patch_verification configuration will have no effect.',
), ),
).called(1); ).called(1);
@@ -297,6 +297,12 @@ To change the version of this release, change your app's version in your pubspec
group('when public key is passed as an arg', () { group('when public key is passed as an arg', () {
setUp(() { setUp(() {
final publicKeyFile = File(
p.join(
Directory.systemTemp.createTempSync().path,
'public-key.pem',
),
)..createSync(recursive: true);
when( when(
() => artifactBuilder.buildWindowsApp( () => artifactBuilder.buildWindowsApp(
target: any(named: 'target'), target: any(named: 'target'),
@@ -309,9 +315,9 @@ To change the version of this release, change your app's version in your pubspec
).thenReturn(true); ).thenReturn(true);
when( when(
() => argResults[CommonArguments.publicKeyArg.name], () => argResults[CommonArguments.publicKeyArg.name],
).thenReturn('public_key'); ).thenReturn(publicKeyFile.path);
when( when(
() => codeSigner.base64PublicKey(any()), () => codeSigner.base64PublicKeyFromPem(any()),
).thenReturn('encoded_public_key'); ).thenReturn('encoded_public_key');
}); });
@@ -326,6 +332,45 @@ To change the version of this release, change your app's version in your pubspec
).called(1); ).called(1);
}); });
}); });
group('when a public-key-cmd is provided', () {
setUp(() {
when(
() => artifactBuilder.buildWindowsApp(
target: any(named: 'target'),
args: any(named: 'args'),
base64PublicKey: any(named: 'base64PublicKey'),
),
).thenAnswer((_) async => projectRoot);
when(
() => argResults[CommonArguments.publicKeyCmd.name],
).thenReturn('get-key-cmd');
when(
() => argResults.wasParsed(CommonArguments.publicKeyCmd.name),
).thenReturn(true);
when(
() => codeSigner.runPublicKeyCmd(any()),
).thenAnswer((_) async => 'pem-public-key');
when(
() => codeSigner.base64PublicKeyFromPem(any()),
).thenReturn('encoded_public_key_from_cmd');
});
test('passes public key to buildWindowsApp', () async {
await runWithOverrides(releaser.buildReleaseArtifacts);
verify(
() => codeSigner.runPublicKeyCmd('get-key-cmd'),
).called(1);
verify(
() => artifactBuilder.buildWindowsApp(
base64PublicKey: 'encoded_public_key_from_cmd',
target: any(named: 'target'),
args: any(named: 'args'),
),
).called(1);
});
});
}); });
group('getReleaseVersion', () { group('getReleaseVersion', () {
@@ -1,11 +1,29 @@
// cspell:ignore qwer // cspell:ignore qwer
import 'dart:io';
import 'package:args/args.dart'; import 'package:args/args.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/code_signer.dart';
import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/common_arguments.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/release_type.dart'; import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
class MockCodeSigner extends Mock implements CodeSigner {}
class MockShorebirdLogger extends Mock implements ShorebirdLogger {}
class FakeFile extends Fake implements File {}
void main() { void main() {
setUpAll(() {
registerFallbackValue(FakeFile());
});
group('OptionFinder', () { group('OptionFinder', () {
late ArgParser argParser; late ArgParser argParser;
@@ -300,4 +318,225 @@ void main() {
}); });
}); });
}); });
group('CodeSign', () {
final cryptoFixturesBasePath = p.join('test', 'fixtures', 'crypto');
final publicKeyFile = File(p.join(cryptoFixturesBasePath, 'public.pem'));
late ArgParser parser;
late ShorebirdLogger logger;
setUp(() {
logger = MockShorebirdLogger();
parser = ArgParser()
..addOption(CommonArguments.publicKeyArg.name)
..addOption(CommonArguments.privateKeyArg.name)
..addOption(CommonArguments.publicKeyCmd.name)
..addOption(CommonArguments.signCmd.name);
});
group('assertAbsentOrValidKeyPairOrCommands', () {
test('succeeds when no signing arguments provided', () {
final args = <String>[];
final result = parser.parse(args);
expect(result.assertAbsentOrValidKeyPairOrCommands, returnsNormally);
});
test('throws when both public key sources provided', () {
final args = [
'--${CommonArguments.publicKeyArg.name}=${publicKeyFile.path}',
'--${CommonArguments.publicKeyCmd.name}=get-key-cmd',
'--${CommonArguments.signCmd.name}=sign-cmd',
];
final result = parser.parse(args);
runScoped(
() {
expect(
result.assertAbsentOrValidKeyPairOrCommands,
throwsA(isA<ProcessExit>()),
);
},
values: {loggerRef.overrideWith(() => logger)},
);
});
test('throws when both signing methods provided', () {
final privateKeyFile = File(
p.join(cryptoFixturesBasePath, 'private.pem'),
);
final args = [
'--${CommonArguments.publicKeyArg.name}=${publicKeyFile.path}',
'--${CommonArguments.privateKeyArg.name}=${privateKeyFile.path}',
'--${CommonArguments.signCmd.name}=sign-cmd',
];
final result = parser.parse(args);
runScoped(
() {
expect(
result.assertAbsentOrValidKeyPairOrCommands,
throwsA(isA<ProcessExit>()),
);
},
values: {loggerRef.overrideWith(() => logger)},
);
});
test('throws when sign-cmd provided without any public key', () {
final args = ['--${CommonArguments.signCmd.name}=sign-cmd'];
final result = parser.parse(args);
runScoped(
() {
expect(
result.assertAbsentOrValidKeyPairOrCommands,
throwsA(isA<ProcessExit>()),
);
},
values: {loggerRef.overrideWith(() => logger)},
);
});
test('succeeds when both cmd arguments provided', () {
final args = [
'--${CommonArguments.publicKeyCmd.name}=get-key-cmd',
'--${CommonArguments.signCmd.name}=sign-cmd',
];
final result = parser.parse(args);
expect(result.assertAbsentOrValidKeyPairOrCommands, returnsNormally);
});
test('succeeds with public-key-path + sign-cmd (mixed)', () {
final args = [
'--${CommonArguments.publicKeyArg.name}=${publicKeyFile.path}',
'--${CommonArguments.signCmd.name}=sign-cmd',
];
final result = parser.parse(args);
expect(result.assertAbsentOrValidKeyPairOrCommands, returnsNormally);
});
test('succeeds when both file arguments provided with valid files', () {
final privateKeyFile = File(
p.join(cryptoFixturesBasePath, 'private.pem'),
);
final args = [
'--${CommonArguments.publicKeyArg.name}=${publicKeyFile.path}',
'--${CommonArguments.privateKeyArg.name}=${privateKeyFile.path}',
];
final result = parser.parse(args);
expect(result.assertAbsentOrValidKeyPairOrCommands, returnsNormally);
});
});
group('assertAbsentOrValidPublicKeyOrCmd', () {
test('succeeds when no public key arguments provided', () {
final args = <String>[];
final result = parser.parse(args);
expect(result.assertAbsentOrValidPublicKeyOrCmd, returnsNormally);
});
test('succeeds when only public-key-path provided', () {
final args = [
'--${CommonArguments.publicKeyArg.name}=${publicKeyFile.path}',
];
final result = parser.parse(args);
expect(result.assertAbsentOrValidPublicKeyOrCmd, returnsNormally);
});
test('succeeds when only public-key-cmd provided', () {
final args = ['--${CommonArguments.publicKeyCmd.name}=get-key-cmd'];
final result = parser.parse(args);
expect(result.assertAbsentOrValidPublicKeyOrCmd, returnsNormally);
});
test('throws when both public-key-path and public-key-cmd provided', () {
final args = [
'--${CommonArguments.publicKeyArg.name}=${publicKeyFile.path}',
'--${CommonArguments.publicKeyCmd.name}=get-key-cmd',
];
final result = parser.parse(args);
runScoped(
() {
expect(
result.assertAbsentOrValidPublicKeyOrCmd,
throwsA(isA<ProcessExit>()),
);
},
values: {loggerRef.overrideWith(() => logger)},
);
});
});
group('getEncodedPublicKey', () {
late CodeSigner codeSigner;
setUp(() {
codeSigner = MockCodeSigner();
});
test('returns null when no public key configured', () async {
final args = <String>[];
final result = parser.parse(args);
await runScoped(
() async {
final encoded = await result.getEncodedPublicKey();
expect(encoded, isNull);
},
values: {codeSignerRef.overrideWith(() => codeSigner)},
);
});
test(
'returns encoded key from file when public-key-path provided',
() async {
final args = [
'--${CommonArguments.publicKeyArg.name}=${publicKeyFile.path}',
];
final result = parser.parse(args);
when(
() => codeSigner.base64PublicKeyFromPem(any()),
).thenReturn('encoded-key');
await runScoped(
() async {
final encoded = await result.getEncodedPublicKey();
expect(encoded, equals('encoded-key'));
},
values: {codeSignerRef.overrideWith(() => codeSigner)},
);
},
);
test(
'returns encoded key from cmd when public-key-cmd provided',
() async {
final args = ['--${CommonArguments.publicKeyCmd.name}=get-key-cmd'];
final result = parser.parse(args);
when(
() => codeSigner.runPublicKeyCmd(any()),
).thenAnswer((_) async => 'pem-key');
when(
() => codeSigner.base64PublicKeyFromPem(any()),
).thenReturn('encoded-key');
await runScoped(
() async {
final encoded = await result.getEncodedPublicKey();
expect(encoded, equals('encoded-key'));
verify(() => codeSigner.runPublicKeyCmd('get-key-cmd')).called(1);
verify(
() => codeSigner.base64PublicKeyFromPem('pem-key'),
).called(1);
},
values: {codeSignerRef.overrideWith(() => codeSigner)},
);
},
);
});
});
} }