diff --git a/gui/lib/patcher/patch_manifest.dart b/gui/lib/patcher/patch_manifest.dart new file mode 100644 index 0000000..358dd4f --- /dev/null +++ b/gui/lib/patcher/patch_manifest.dart @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// OpenROM — Universal ROM Compression Suite +// M5 Dev | GPL v3. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart' as crypto; + +import 'checksums.dart'; +import 'patcher.dart'; + +/// Stable identity information for a ROM used to build a patch. +class RomIdentity { + final String fileName; + final int sizeBytes; + final String crc32; + final String sha256; + + const RomIdentity({ + required this.fileName, + required this.sizeBytes, + required this.crc32, + required this.sha256, + }); + + static Future fromFile(File file) async { + final bytes = await file.readAsBytes(); + return RomIdentity( + fileName: file.uri.pathSegments.last, + sizeBytes: bytes.length, + crc32: crc32Bytes(bytes).toRadixString(16).padLeft(8, '0'), + sha256: crypto.sha256.convert(bytes).toString(), + ); + } + + Map toJson() => { + 'fileName': fileName, + 'sizeBytes': sizeBytes, + 'crc32': crc32, + 'sha256': sha256, + }; + + factory RomIdentity.fromJson(Map json) => RomIdentity( + fileName: json['fileName'] as String? ?? '', + sizeBytes: json['sizeBytes'] as int, + crc32: json['crc32'] as String, + sha256: json['sha256'] as String, + ); +} + +/// Sidecar metadata written next to generated patches. +/// +/// The sidecar keeps the original patch bytes compatible with existing tools, +/// while making the expected source ROM explicit for formats such as IPS and +/// xdelta that do not carry a source identity themselves. +class PatchManifest { + static const int schemaVersion = 1; + + final String format; + final RomIdentity baseRom; + final RomIdentity targetRom; + + const PatchManifest({ + required this.format, + required this.baseRom, + required this.targetRom, + }); + + Map toJson() => { + 'schemaVersion': schemaVersion, + 'format': format, + 'baseRom': baseRom.toJson(), + 'targetRom': targetRom.toJson(), + }; + + factory PatchManifest.fromJson(Map json) { + if (json['schemaVersion'] != schemaVersion) { + throw PatchException('Unsupported patch manifest schema.'); + } + return PatchManifest( + format: json['format'] as String? ?? 'unknown', + baseRom: RomIdentity.fromJson(json['baseRom'] as Map), + targetRom: + RomIdentity.fromJson(json['targetRom'] as Map), + ); + } + + static String sidecarPath(String patchPath) => '$patchPath.json'; + + static Future readForPatch(File patchFile) async { + final sidecar = File(sidecarPath(patchFile.path)); + if (!await sidecar.exists()) return null; + + try { + final decoded = jsonDecode(await sidecar.readAsString()); + if (decoded is! Map) { + throw const FormatException('Manifest root must be an object.'); + } + return PatchManifest.fromJson(decoded); + } on PatchException { + rethrow; + } catch (e) { + throw PatchException('Invalid patch manifest: $e'); + } + } + + Future writeForPatch(File patchFile) async { + final sidecar = File(sidecarPath(patchFile.path)); + final contents = const JsonEncoder.withIndent(' ').convert(toJson()); + await sidecar.writeAsString('$contents\n'); + } + + Future validateBase(File romFile) async { + if (!await romFile.exists()) { + throw PatchException('Base ROM not found: ${romFile.path}'); + } + + final actual = await RomIdentity.fromFile(romFile); + if (actual.sizeBytes != baseRom.sizeBytes || + actual.sha256.toLowerCase() != baseRom.sha256.toLowerCase()) { + throw PatchException( + 'Base ROM mismatch. Expected ${baseRom.fileName} ' + '(size ${baseRom.sizeBytes}, SHA-256 ${baseRom.sha256}), ' + 'but received ${actual.fileName} ' + '(size ${actual.sizeBytes}, SHA-256 ${actual.sha256}).', + ); + } + } +} diff --git a/gui/lib/screens/patcher_screen.dart b/gui/lib/screens/patcher_screen.dart index 48f0edb..f6e20d5 100644 --- a/gui/lib/screens/patcher_screen.dart +++ b/gui/lib/screens/patcher_screen.dart @@ -1041,6 +1041,16 @@ class _BuildPatchTabState extends State<_BuildPatchTab> { fontSize: 13, ), ), + if (_report!.manifestPath != null) ...[ + const SizedBox(height: 6), + Text( + 'Base ROM manifest: ${_report!.manifestPath}', + style: TextStyle( + color: theme.textSecondary, + fontSize: 13, + ), + ), + ], ], ), ), diff --git a/gui/lib/services/patch_builder_service.dart b/gui/lib/services/patch_builder_service.dart index 109058f..8f4aecc 100644 --- a/gui/lib/services/patch_builder_service.dart +++ b/gui/lib/services/patch_builder_service.dart @@ -14,6 +14,7 @@ import '../patcher/dcp_builder.dart'; import '../patcher/ebp_builder.dart'; import '../patcher/ips32_builder.dart'; import '../patcher/ips_builder.dart'; +import '../patcher/patch_manifest.dart'; import '../patcher/patcher.dart'; import '../patcher/ppf_builder.dart'; import '../patcher/ups_builder.dart'; @@ -24,11 +25,13 @@ class PatchBuildReport { final String format; final int outputSizeBytes; final String outputPath; + final String? manifestPath; const PatchBuildReport({ required this.format, required this.outputSizeBytes, required this.outputPath, + this.manifestPath, }); } @@ -163,12 +166,24 @@ Future _buildPatchIsolate(Map params) async { throw PatchException('Patch file was not generated.'); } + String? manifestPath; + if (format != 'dcp') { + final manifest = PatchManifest( + format: format.toUpperCase(), + baseRom: await RomIdentity.fromFile(origFile), + targetRom: await RomIdentity.fromFile(modFile), + ); + await manifest.writeForPatch(outFile); + manifestPath = PatchManifest.sidecarPath(outputPath); + } + final size = await outFile.length(); return PatchBuildReport( format: format.toUpperCase(), outputSizeBytes: size, outputPath: outputPath, + manifestPath: manifestPath, ); } diff --git a/gui/lib/services/patcher_service.dart b/gui/lib/services/patcher_service.dart index 40de517..ec478cb 100644 --- a/gui/lib/services/patcher_service.dart +++ b/gui/lib/services/patcher_service.dart @@ -6,6 +6,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import '../models/errors.dart'; +import '../patcher/patch_manifest.dart'; import '../patcher/patcher.dart'; import '../patcher/patcher_factory.dart'; import '../patcher/dcp_patcher.dart'; @@ -18,13 +19,27 @@ Future _applyPatchIsolate(Map params) async { final outputPath = params['outputPath'] as String; final ignoreChecksum = params['ignoreChecksum'] as bool; + final manifest = await PatchManifest.readForPatch(File(patchPath)); + if (manifest != null && !ignoreChecksum) { + await manifest.validateBase(File(romPath)); + } + final patcher = PatcherFactory.create( patchFile: File(patchPath), romFile: File(romPath), outputFile: File(outputPath), ); - return await patcher.apply(ignoreChecksum: ignoreChecksum); + final report = await patcher.apply(ignoreChecksum: ignoreChecksum); + if (manifest == null || ignoreChecksum) return report; + + return PatchReport( + format: report.format, + checks: [ + const PatchCheck('Base ROM (SHA-256 manifest)', CheckOutcome.passed), + ...report.checks, + ], + ); } class PatcherService { diff --git a/gui/pubspec.lock b/gui/pubspec.lock index be7c201..6f4ae34 100644 --- a/gui/pubspec.lock +++ b/gui/pubspec.lock @@ -74,7 +74,7 @@ packages: source: hosted version: "0.3.5+5" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/gui/pubspec.yaml b/gui/pubspec.yaml index 71bdf53..0e52c6a 100644 --- a/gui/pubspec.yaml +++ b/gui/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter + crypto: ^3.0.6 intl: ^0.20.2 path: ^1.9.0 desktop_drop: ^0.4.0 diff --git a/gui/test/patch_manifest_test.dart b/gui/test/patch_manifest_test.dart new file mode 100644 index 0000000..1cc195e --- /dev/null +++ b/gui/test/patch_manifest_test.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:gui/patcher/patch_manifest.dart'; +import 'package:gui/patcher/patcher.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('patch_manifest_test_'); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('writes a sidecar with base and target ROM identities', () async { + final base = File('${tempDir.path}/base.rom') + ..writeAsBytesSync([1, 2, 3, 4]); + final target = File('${tempDir.path}/target.rom') + ..writeAsBytesSync([1, 9, 3, 4]); + final patch = File('${tempDir.path}/change.ips')..writeAsBytesSync([0]); + + final manifest = PatchManifest( + format: 'IPS', + baseRom: await RomIdentity.fromFile(base), + targetRom: await RomIdentity.fromFile(target), + ); + await manifest.writeForPatch(patch); + + final loaded = await PatchManifest.readForPatch(patch); + expect(loaded, isNotNull); + expect(loaded!.format, 'IPS'); + expect(loaded.baseRom.sizeBytes, 4); + expect(loaded.baseRom.sha256, isNotEmpty); + expect(File('${patch.path}.json').existsSync(), isTrue); + }); + + test('rejects a base ROM with a different hash', () async { + final base = File('${tempDir.path}/base.rom') + ..writeAsBytesSync([1, 2, 3, 4]); + final wrong = File('${tempDir.path}/wrong.rom') + ..writeAsBytesSync([1, 2, 3, 5]); + final target = File('${tempDir.path}/target.rom') + ..writeAsBytesSync([1, 9, 3, 4]); + final patch = File('${tempDir.path}/change.ips')..writeAsBytesSync([0]); + + final manifest = PatchManifest( + format: 'IPS', + baseRom: await RomIdentity.fromFile(base), + targetRom: await RomIdentity.fromFile(target), + ); + await manifest.writeForPatch(patch); + final loaded = await PatchManifest.readForPatch(patch); + + expect( + () => loaded!.validateBase(wrong), + throwsA(isA()), + ); + }); +}