feat: verify embedded backup media checksums

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 13:30:06 +02:00
parent 4384797e25
commit 8d6e5b4201
6 changed files with 71 additions and 63 deletions

View File

@ -200,6 +200,7 @@ final class EmbeddedBackupMediaFile {
required this.fileName, required this.fileName,
required this.mimeType, required this.mimeType,
required this.sizeBytes, required this.sizeBytes,
required this.sha256,
required this.base64, required this.base64,
}); });
@ -208,6 +209,7 @@ final class EmbeddedBackupMediaFile {
final String fileName; final String fileName;
final String? mimeType; final String? mimeType;
final int sizeBytes; final int sizeBytes;
final String sha256;
final String base64; final String base64;
} }

View File

@ -4900,6 +4900,7 @@ Map<String, Object?> _mediaFileToJson(EmbeddedBackupMediaFile file) => {
'fileName': file.fileName, 'fileName': file.fileName,
'mimeType': file.mimeType, 'mimeType': file.mimeType,
'sizeBytes': file.sizeBytes, 'sizeBytes': file.sizeBytes,
'sha256': file.sha256,
'base64': file.base64, 'base64': file.base64,
}; };
@ -4949,6 +4950,7 @@ List<EmbeddedBackupMediaFile> _mediaFilesFromBackup(Object? value) {
fileName: _stringFromBackup(payload, 'fileName'), fileName: _stringFromBackup(payload, 'fileName'),
mimeType: payload['mimeType'] as String?, mimeType: payload['mimeType'] as String?,
sizeBytes: payload['sizeBytes'] as int? ?? 0, sizeBytes: payload['sizeBytes'] as int? ?? 0,
sha256: _stringFromBackup(payload, 'sha256'),
base64: _stringFromBackup(payload, 'base64'), base64: _stringFromBackup(payload, 'base64'),
); );
}) })

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto;
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@ -101,6 +102,7 @@ final class PathProviderLocalMediaStorage
fileName: p.basename(file.path), fileName: p.basename(file.path),
mimeType: resource.payload['mimeType'] as String?, mimeType: resource.payload['mimeType'] as String?,
sizeBytes: bytes.length, sizeBytes: bytes.length,
sha256: crypto.sha256.convert(bytes).toString(),
base64: base64Encode(bytes), base64: base64Encode(bytes),
), ),
); );
@ -123,6 +125,9 @@ final class PathProviderLocalMediaStorage
if (bytes == null) { if (bytes == null) {
continue; continue;
} }
if (crypto.sha256.convert(bytes).toString() != file.sha256) {
continue;
}
final extension = p.extension(file.fileName).toLowerCase(); final extension = p.extension(file.fileName).toLowerCase();
final target = File( final target = File(
p.join(directory.path, '${file.mediaAssetId}$extension'), p.join(directory.path, '${file.mediaAssetId}$extension'),

View File

@ -226,7 +226,7 @@ packages:
source: hosted source: hosted
version: "0.3.5+4" version: "0.3.5+4"
crypto: crypto:
dependency: transitive dependency: "direct main"
description: description:
name: crypto name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf

View File

@ -11,6 +11,7 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
crypto: ^3.0.6
drift: ^2.34.2 drift: ^2.34.2
drift_flutter: ^0.3.1 drift_flutter: ^0.3.1
flutter_secure_storage: ^9.2.4 flutter_secure_storage: ^9.2.4

View File

@ -2359,6 +2359,8 @@ void main() {
fileName: 'media-1.png', fileName: 'media-1.png',
mimeType: 'image/png', mimeType: 'image/png',
sizeBytes: 4, sizeBytes: 4,
sha256:
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
base64: 'dGVzdA==', base64: 'dGVzdA==',
), ),
], ],
@ -2444,6 +2446,8 @@ void main() {
fileName: 'media-1.png', fileName: 'media-1.png',
mimeType: 'image/png', mimeType: 'image/png',
sizeBytes: 4, sizeBytes: 4,
sha256:
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
base64: 'dGVzdA==', base64: 'dGVzdA==',
), ),
], ],
@ -2485,71 +2489,65 @@ void main() {
expect(repository.appliedModes, isEmpty); expect(repository.appliedModes, isEmpty);
}); });
test( test('Data import use case restores media and applies selected mode', () async {
'Data import use case restores media and applies selected mode', final repository = _FakeLocalDataBackupRepository();
() async { final mediaStore = _FakeLocalBackupMediaStore(
final repository = _FakeLocalDataBackupRepository(); restored: const {
final mediaStore = _FakeLocalBackupMediaStore( 'media-1': RestoredMediaFile(
restored: const { mediaAssetId: 'media-1',
'media-1': RestoredMediaFile( localUri: 'file:///restored/media-1.png',
mediaAssetId: 'media-1', sizeBytes: 4,
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==',
),
],
), ),
); },
);
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( final result = await useCase.importFrom(
bytes, bytes,
mode: LocalBackupImportMode.replaceAll, mode: LocalBackupImportMode.replaceAll,
); );
expect(result.insertedCount, 0); expect(result.insertedCount, 0);
expect(repository.appliedModes, [LocalBackupImportMode.replaceAll]); expect(repository.appliedModes, [LocalBackupImportMode.replaceAll]);
expect( expect(
repository repository.appliedSnapshots.single.mediaAssets.single.payload['localUri'],
.appliedSnapshots 'file:///restored/media-1.png',
.single );
.mediaAssets });
.single
.payload['localUri'],
'file:///restored/media-1.png',
);
},
);
test('Program use case duplicates deeply with copy name conflicts', () async { test('Program use case duplicates deeply with copy name conflicts', () async {
final programRepository = _FakeProgramRepository(); final programRepository = _FakeProgramRepository();