Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions gui/lib/patcher/patch_manifest.dart
Original file line number Diff line number Diff line change
@@ -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<RomIdentity> 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<String, dynamic> toJson() => {
'fileName': fileName,
'sizeBytes': sizeBytes,
'crc32': crc32,
'sha256': sha256,
};

factory RomIdentity.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() => {
'schemaVersion': schemaVersion,
'format': format,
'baseRom': baseRom.toJson(),
'targetRom': targetRom.toJson(),
};

factory PatchManifest.fromJson(Map<String, dynamic> 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<String, dynamic>),
targetRom:
RomIdentity.fromJson(json['targetRom'] as Map<String, dynamic>),
);
}

static String sidecarPath(String patchPath) => '$patchPath.json';

static Future<PatchManifest?> 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<String, dynamic>) {
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<void> writeForPatch(File patchFile) async {
final sidecar = File(sidecarPath(patchFile.path));
final contents = const JsonEncoder.withIndent(' ').convert(toJson());
await sidecar.writeAsString('$contents\n');
}

Future<void> 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}).',
);
}
}
}
10 changes: 10 additions & 0 deletions gui/lib/screens/patcher_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
),
],
],
),
),
Expand Down
15 changes: 15 additions & 0 deletions gui/lib/services/patch_builder_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
});
}

Expand Down Expand Up @@ -163,12 +166,24 @@ Future<PatchBuildReport> _buildPatchIsolate(Map<String, String> 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,
);
}

Expand Down
17 changes: 16 additions & 1 deletion gui/lib/services/patcher_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,13 +19,27 @@ Future<PatchReport> _applyPatchIsolate(Map<String, dynamic> 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 {
Expand Down
2 changes: 1 addition & 1 deletion gui/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ packages:
source: hosted
version: "0.3.5+5"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
Expand Down
1 change: 1 addition & 0 deletions gui/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions gui/test/patch_manifest_test.dart
Original file line number Diff line number Diff line change
@@ -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<PatchException>()),
);
});
}
Loading