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.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;
}

View File

@ -4900,6 +4900,7 @@ Map<String, Object?> _mediaFileToJson(EmbeddedBackupMediaFile file) => {
'fileName': file.fileName,
'mimeType': file.mimeType,
'sizeBytes': file.sizeBytes,
'sha256': file.sha256,
'base64': file.base64,
};
@ -4949,6 +4950,7 @@ List<EmbeddedBackupMediaFile> _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'),
);
})

View File

@ -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'),

View File

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

View File

@ -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

View File

@ -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();