|
1 | | -import 'dart:async' show StreamController; |
| 1 | +import 'dart:async'; |
2 | 2 | import 'dart:typed_data'; |
3 | 3 | import 'package:m_security/src/rust/api/streaming.dart' as rust_streaming; |
4 | 4 | import 'package:m_security/src/rust/api/encryption.dart' as rust_encryption; |
5 | 5 | import 'package:m_security/src/rust/api/hashing.dart' as rust_hashing; |
6 | | -import 'dart:io'; |
7 | 6 |
|
8 | | -/// Service for streaming file operations. (encrypt, decrypt, hash) |
| 7 | +/// Streaming file operations (encrypt, decrypt, hash). |
9 | 8 | /// |
10 | | -///Process large files in 64KB chunks to maintain constant RAM usage |
11 | | -///regardless of file size. |
12 | | -
|
| 9 | +/// Processes large files in 64KB chunks to maintain constant RAM usage |
| 10 | +/// regardless of file size. |
13 | 11 | class StreamingService { |
14 | 12 | StreamingService._(); |
15 | 13 |
|
16 | 14 | /// Encrypt a file, writing the result to outputPath. |
17 | 15 | /// Returns a Stream of progress (0.0 to 1.0). |
18 | | - /// |
19 | | - /// The encrypted file uses uniform-size chunks — the last chunk |
20 | | - /// is padded so all chunks are the same size on disk. |
21 | 16 | static Stream<double> encryptFile({ |
22 | 17 | required String inputPath, |
23 | 18 | required String outputPath, |
24 | 19 | required rust_encryption.CipherHandle cipher, |
25 | 20 | }) { |
26 | | - final inputFile = File(inputPath); |
27 | | - if (!inputFile.existsSync()) { |
28 | | - throw Exception('Input file does not exist: $inputPath'); |
29 | | - } |
30 | | - |
31 | | - try { |
32 | | - return rust_streaming.streamEncryptFile( |
33 | | - cipher: cipher, |
34 | | - inputPath: inputPath, |
35 | | - outputPath: outputPath, |
36 | | - ); |
37 | | - } catch (e) { |
38 | | - throw Exception('Stream encrypt failed: $e'); |
39 | | - } |
| 21 | + return _guardedStream(() => rust_streaming.streamEncryptFile( |
| 22 | + cipher: cipher, |
| 23 | + inputPath: inputPath, |
| 24 | + outputPath: outputPath, |
| 25 | + )); |
40 | 26 | } |
41 | 27 |
|
42 | 28 | /// Decrypt a streaming-encrypted file. |
43 | 29 | /// Returns a Stream of progress (0.0 to 1.0). |
44 | | - /// |
45 | | - /// Reads chunks until is_final sentinel is found. |
46 | | - /// Strips padding from the last chunk automatically. |
47 | | - /// Validates padding bytes are zero (tampered padding → error). |
48 | 30 | static Stream<double> decryptFile({ |
49 | | - required String inputPath, |
50 | | - required String outputPath, |
51 | | - required rust_encryption.CipherHandle cipher, |
52 | | -}) { |
53 | | - if (!File(inputPath).existsSync()) { |
54 | | - return Stream.error(Exception('Input file does not exist: $inputPath')); |
| 31 | + required String inputPath, |
| 32 | + required String outputPath, |
| 33 | + required rust_encryption.CipherHandle cipher, |
| 34 | + }) { |
| 35 | + return _guardedStream(() => rust_streaming.streamDecryptFile( |
| 36 | + cipher: cipher, |
| 37 | + inputPath: inputPath, |
| 38 | + outputPath: outputPath, |
| 39 | + )); |
55 | 40 | } |
56 | 41 |
|
57 | | - final controller = StreamController<double>(); |
58 | | - final sourceStream = rust_streaming.streamDecryptFile( |
59 | | - cipher: cipher, |
60 | | - inputPath: inputPath, |
61 | | - outputPath: outputPath, |
62 | | - ); |
63 | | - |
64 | | - sourceStream.listen( |
65 | | - controller.add, |
66 | | - onError: controller.addError, |
67 | | - onDone: () { |
68 | | - if (!controller.isClosed) controller.close(); |
69 | | - }, |
70 | | - cancelOnError: false, |
71 | | - ); |
72 | | - |
73 | | - return controller.stream; |
74 | | -} |
75 | | - |
76 | 42 | /// Hash a file without loading it into memory. |
77 | | - /// Returns the digest bytes. Optionally reports progress. |
78 | | - /// |
79 | | - /// Uses raw file bytes (no encryption padding) so the digest |
| 43 | + /// Returns the digest bytes. |
80 | 44 | static Future<Uint8List> hashFile({ |
81 | 45 | required String filePath, |
82 | 46 | required rust_hashing.HasherHandle hasher, |
83 | | - void Function(double progress)? onProgress, |
84 | 47 | }) async { |
85 | | - final file = File(filePath); |
86 | | - if (!await file.exists()) { |
87 | | - throw Exception('File does not exist: $filePath'); |
88 | | - } |
89 | | - try { |
90 | | - //Stream the file into hasher |
91 | | - await rust_streaming.streamHashFile( |
92 | | - hasher: hasher, |
93 | | - filePath: filePath, |
94 | | - ).last; |
95 | | - return await rust_hashing.hasherFinalize(handle: hasher); |
96 | | - } catch (e) { |
97 | | - throw Exception('Stream hash failed: $e'); |
98 | | - } |
| 48 | + await _guardedStream(() => rust_streaming.streamHashFile( |
| 49 | + hasher: hasher, |
| 50 | + filePath: filePath, |
| 51 | + )).drain(); |
| 52 | + return await rust_hashing.hasherFinalize(handle: hasher); |
| 53 | + } |
| 54 | + |
| 55 | + // FRB stream functions use unawaited(handler.executeNormal(...)), |
| 56 | + // so the Rust error is thrown as a zone error, not a stream error. |
| 57 | + // The error arrives AFTER the progress stream closes, so we delay |
| 58 | + // closing the controller to let the zone handler forward it first. |
| 59 | + static Stream<double> _guardedStream(Stream<double> Function() factory) { |
| 60 | + final controller = StreamController<double>(); |
| 61 | + runZonedGuarded(() { |
| 62 | + factory().listen( |
| 63 | + controller.add, |
| 64 | + onError: controller.addError, |
| 65 | + onDone: () { |
| 66 | + // FRB delivers errors after the stream closes — schedule close |
| 67 | + // in the event loop so pending microtasks (zone errors) run first. |
| 68 | + Future(() { |
| 69 | + if (!controller.isClosed) controller.close(); |
| 70 | + }); |
| 71 | + }, |
| 72 | + ); |
| 73 | + }, (error, stack) { |
| 74 | + if (!controller.isClosed) { |
| 75 | + controller.addError(error, stack); |
| 76 | + controller.close(); |
| 77 | + } |
| 78 | + }); |
| 79 | + return controller.stream; |
99 | 80 | } |
100 | 81 | } |
0 commit comments