From 8d6e5b42017996906f98862ee468c51b6ca867cc Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 22 Jul 2026 13:30:06 +0200 Subject: [PATCH] feat: verify embedded backup media checksums Co-Authored-By: Claude Opus 4.8 --- lib/application/ports.dart | 2 + lib/application/use_cases.dart | 2 + .../local/local_media_storage.dart | 5 + pubspec.lock | 2 +- pubspec.yaml | 1 + test/application/use_cases_test.dart | 122 +++++++++--------- 6 files changed, 71 insertions(+), 63 deletions(-) diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 90d7d72..7be22f9 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -200,6 +200,7 @@ final class EmbeddedBackupMediaFile { required this.fileName, required this.mimeType, required this.sizeBytes, + required this.sha256, required this.base64, }); @@ -208,6 +209,7 @@ final class EmbeddedBackupMediaFile { final String fileName; final String? mimeType; final int sizeBytes; + final String sha256; final String base64; } diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index ec539ef..05beaef 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -4900,6 +4900,7 @@ Map _mediaFileToJson(EmbeddedBackupMediaFile file) => { 'fileName': file.fileName, 'mimeType': file.mimeType, 'sizeBytes': file.sizeBytes, + 'sha256': file.sha256, 'base64': file.base64, }; @@ -4949,6 +4950,7 @@ List _mediaFilesFromBackup(Object? value) { fileName: _stringFromBackup(payload, 'fileName'), mimeType: payload['mimeType'] as String?, sizeBytes: payload['sizeBytes'] as int? ?? 0, + sha256: _stringFromBackup(payload, 'sha256'), base64: _stringFromBackup(payload, 'base64'), ); }) diff --git a/lib/infrastructure/local/local_media_storage.dart b/lib/infrastructure/local/local_media_storage.dart index 8b48562..da56751 100644 --- a/lib/infrastructure/local/local_media_storage.dart +++ b/lib/infrastructure/local/local_media_storage.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'package:crypto/crypto.dart' as crypto; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -101,6 +102,7 @@ final class PathProviderLocalMediaStorage fileName: p.basename(file.path), mimeType: resource.payload['mimeType'] as String?, sizeBytes: bytes.length, + sha256: crypto.sha256.convert(bytes).toString(), base64: base64Encode(bytes), ), ); @@ -123,6 +125,9 @@ final class PathProviderLocalMediaStorage if (bytes == null) { continue; } + if (crypto.sha256.convert(bytes).toString() != file.sha256) { + continue; + } final extension = p.extension(file.fileName).toLowerCase(); final target = File( p.join(directory.path, '${file.mediaAssetId}$extension'), diff --git a/pubspec.lock b/pubspec.lock index d4625b1..a9a8567 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -226,7 +226,7 @@ packages: source: hosted version: "0.3.5+4" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/pubspec.yaml b/pubspec.yaml index 4a04a36..934f5a0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.8 + crypto: ^3.0.6 drift: ^2.34.2 drift_flutter: ^0.3.1 flutter_secure_storage: ^9.2.4 diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index eab0aac..e166073 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -2359,6 +2359,8 @@ void main() { fileName: 'media-1.png', mimeType: 'image/png', sizeBytes: 4, + sha256: + '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', base64: 'dGVzdA==', ), ], @@ -2444,6 +2446,8 @@ void main() { fileName: 'media-1.png', mimeType: 'image/png', sizeBytes: 4, + sha256: + '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', base64: 'dGVzdA==', ), ], @@ -2485,71 +2489,65 @@ void main() { expect(repository.appliedModes, isEmpty); }); - test( - 'Data import use case restores media and applies selected mode', - () async { - final repository = _FakeLocalDataBackupRepository(); - final mediaStore = _FakeLocalBackupMediaStore( - restored: const { - 'media-1': RestoredMediaFile( - mediaAssetId: 'media-1', - localUri: 'file:///restored/media-1.png', - sizeBytes: 4, - ), - }, - ); - final useCase = DataImportUseCase( - repository: repository, - mediaStore: mediaStore, - clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)), - ); - final bytes = const LocalBackupCodec().encode( - _backupSnapshot( - mediaAssets: [ - _backupResource( - id: 'media-1', - updatedAt: DateTime.utc(2026, 7, 22, 10), - payload: { - 'id': 'media-1', - 'metadata': _backupMetadata( - 'media-1', - DateTime.utc(2026, 7, 22, 10), - ), - 'localUri': 'file:///old/media-1.png', - }, - ), - ], - mediaFiles: const [ - EmbeddedBackupMediaFile( - mediaAssetId: 'media-1', - role: 'original', - fileName: 'media-1.png', - mimeType: 'image/png', - sizeBytes: 4, - base64: 'dGVzdA==', - ), - ], + test('Data import use case restores media and applies selected mode', () async { + final repository = _FakeLocalDataBackupRepository(); + final mediaStore = _FakeLocalBackupMediaStore( + restored: const { + 'media-1': RestoredMediaFile( + mediaAssetId: 'media-1', + localUri: 'file:///restored/media-1.png', + sizeBytes: 4, ), - ); + }, + ); + final useCase = DataImportUseCase( + repository: repository, + mediaStore: mediaStore, + clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)), + ); + final bytes = const LocalBackupCodec().encode( + _backupSnapshot( + mediaAssets: [ + _backupResource( + id: 'media-1', + updatedAt: DateTime.utc(2026, 7, 22, 10), + payload: { + 'id': 'media-1', + 'metadata': _backupMetadata( + 'media-1', + DateTime.utc(2026, 7, 22, 10), + ), + 'localUri': 'file:///old/media-1.png', + }, + ), + ], + mediaFiles: const [ + EmbeddedBackupMediaFile( + mediaAssetId: 'media-1', + role: 'original', + fileName: 'media-1.png', + mimeType: 'image/png', + sizeBytes: 4, + sha256: + '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + base64: 'dGVzdA==', + ), + ], + ), + ); - final result = await useCase.importFrom( - bytes, - mode: LocalBackupImportMode.replaceAll, - ); + final result = await useCase.importFrom( + bytes, + mode: LocalBackupImportMode.replaceAll, + ); - expect(result.insertedCount, 0); - expect(repository.appliedModes, [LocalBackupImportMode.replaceAll]); - expect( - repository - .appliedSnapshots - .single - .mediaAssets - .single - .payload['localUri'], - 'file:///restored/media-1.png', - ); - }, - ); + expect(result.insertedCount, 0); + expect(repository.appliedModes, [LocalBackupImportMode.replaceAll]); + expect( + repository.appliedSnapshots.single.mediaAssets.single.payload['localUri'], + 'file:///restored/media-1.png', + ); + }); test('Program use case duplicates deeply with copy name conflicts', () async { final programRepository = _FakeProgramRepository();