Skip to content

Commit f9a7064

Browse files
committed
fix(streaming): wrap FRB streams to catch out-of-band errors FRB uses unawaited(executeNormal) for stream functions, so Rust errors arrive as zone errors after the progress stream closes. _guardedStream uses runZonedGuarded + deferred close to forward these to the stream.
All 8 integration tests pass on macOS, Android, and iOS.
1 parent 2a03e8f commit f9a7064

1 file changed

Lines changed: 51 additions & 70 deletions

File tree

Lines changed: 51 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,81 @@
1-
import 'dart:async' show StreamController;
1+
import 'dart:async';
22
import 'dart:typed_data';
33
import 'package:m_security/src/rust/api/streaming.dart' as rust_streaming;
44
import 'package:m_security/src/rust/api/encryption.dart' as rust_encryption;
55
import 'package:m_security/src/rust/api/hashing.dart' as rust_hashing;
6-
import 'dart:io';
76

8-
/// Service for streaming file operations. (encrypt, decrypt, hash)
7+
/// Streaming file operations (encrypt, decrypt, hash).
98
///
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.
1311
class StreamingService {
1412
StreamingService._();
1513

1614
/// Encrypt a file, writing the result to outputPath.
1715
/// 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.
2116
static Stream<double> encryptFile({
2217
required String inputPath,
2318
required String outputPath,
2419
required rust_encryption.CipherHandle cipher,
2520
}) {
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+
));
4026
}
4127

4228
/// Decrypt a streaming-encrypted file.
4329
/// 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).
4830
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+
));
5540
}
5641

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-
7642
/// 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.
8044
static Future<Uint8List> hashFile({
8145
required String filePath,
8246
required rust_hashing.HasherHandle hasher,
83-
void Function(double progress)? onProgress,
8447
}) 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;
9980
}
10081
}

0 commit comments

Comments
 (0)