From 10db2f272cc31c8d9d11a8231b2361659e75b05e Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:00:45 +0700 Subject: [PATCH] fix(backup): close archive outputs when extraction fails --- lib/services/backup_service.dart | 26 ++++++++++++++------------ test/backup_service_v2_test.dart | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/lib/services/backup_service.dart b/lib/services/backup_service.dart index f63ec9e1..b9fdaa7c 100644 --- a/lib/services/backup_service.dart +++ b/lib/services/backup_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'dart:isolate'; import 'package:archive/archive_io.dart'; +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/constants/app_info.dart'; @@ -371,9 +372,7 @@ class BackupService { String? historyPath; if (historyEntry != null) { historyPath = p.join(extractionDir.path, 'history.ndjson'); - final historyOutput = OutputFileStream(historyPath); - historyEntry.writeContent(historyOutput); - historyOutput.closeSync(); + writeArchiveEntry(historyEntry, OutputFileStream(historyPath)); } final restoredCovers = {}; @@ -399,9 +398,7 @@ class BackupService { var ext = cover['ext']?.toString() ?? '.jpg'; if (!RegExp(r'^\.[a-z0-9]{1,8}$').hasMatch(ext)) ext = '.jpg'; final coverPath = p.join(extractionDir.path, 'cover_${index++}$ext'); - final output = OutputFileStream(coverPath); - archiveEntry.writeContent(output); - output.closeSync(); + writeArchiveEntry(archiveEntry, OutputFileStream(coverPath)); extractedCoverBytes += archiveEntry.size; restoredCovers[manifestEntry.key.toString()] = { 'ext': ext, @@ -428,12 +425,7 @@ class BackupService { throw const FormatException('Missing or oversized profile photo'); } photoPath = p.join(extractionDir.path, 'profile.png'); - final output = OutputFileStream(photoPath); - try { - photoEntry.writeContent(output); - } finally { - output.closeSync(); - } + writeArchiveEntry(photoEntry, OutputFileStream(photoPath)); } profile = UserProfile( name: rawProfile['name'] as String, @@ -469,6 +461,16 @@ class BackupService { } } + /// Consumes and closes the output even when an archive payload cannot be read. + @visibleForTesting + static void writeArchiveEntry(ArchiveFile entry, OutputFileStream output) { + try { + entry.writeContent(output); + } finally { + output.closeSync(); + } + } + static BackupBundle? parse(String content) { dynamic decoded; try { diff --git a/test/backup_service_v2_test.dart b/test/backup_service_v2_test.dart index 5d9ff39b..4d8626a6 100644 --- a/test/backup_service_v2_test.dart +++ b/test/backup_service_v2_test.dart @@ -6,7 +6,38 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:spotiflac_android/services/backup_service.dart'; +class _UnreadableArchiveFile extends ArchiveFile { + _UnreadableArchiveFile(this.failure) : super('history.ndjson', 0, []); + + final FormatException failure; + + @override + void writeContent(OutputStream output, {bool freeMemory = true}) { + output.writeBytes([1, 2, 3]); + throw failure; + } +} + void main() { + test('archive output closes when reading its payload throws', () async { + final root = await Directory.systemTemp.createTemp('unreadable-backup-'); + addTearDown(() => root.delete(recursive: true)); + final path = p.join(root.path, 'history.ndjson'); + final output = OutputFileStream(path); + const failure = FormatException('Invalid compressed archive payload'); + + expect( + () => BackupService.writeArchiveEntry( + _UnreadableArchiveFile(failure), + output, + ), + throwsA(same(failure)), + ); + + expect(output.isOpen, isFalse); + expect(await File(path).readAsBytes(), [1, 2, 3]); + }); + test( 'ZIP backup pages history and streams cover files during restore', () async {