feat: verify embedded backup media checksums
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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'),
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user