feat: send sha256

This commit is contained in:
Tien Do Nam
2026-07-26 23:26:53 +02:00
parent e435cb5140
commit e926f2b9eb
26 changed files with 668 additions and 108 deletions
+1
View File
@@ -220,6 +220,7 @@
"saveToGalleryOff": "Turned off automatically because there are folders." "saveToGalleryOff": "Turned off automatically because there are folders."
}, },
"sendPage": { "sendPage": {
"calculatingChecksum": "Calculating checksum ({curr} / {n})",
"waiting": "Waiting for response…", "waiting": "Waiting for response…",
"rejected": "The recipient has rejected the request.", "rejected": "The recipient has rejected the request.",
"tooManyAttempts": "@:web.tooManyAttempts", "tooManyAttempts": "@:web.tooManyAttempts",
+3 -2
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 55 /// Locales: 55
/// Strings: 18424 (334 per locale) /// Strings: 18425 (335 per locale)
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import
@@ -132,7 +132,8 @@ enum AppLocale with BaseAppLocale<AppLocale, Translations> {
srCyrl(languageCode: 'sr', scriptCode: 'Cyrl'), srCyrl(languageCode: 'sr', scriptCode: 'Cyrl'),
zhCn(languageCode: 'zh', countryCode: 'CN'), zhCn(languageCode: 'zh', countryCode: 'CN'),
zhHk(languageCode: 'zh', countryCode: 'HK'), zhHk(languageCode: 'zh', countryCode: 'HK'),
zhTw(languageCode: 'zh', countryCode: 'TW'); zhTw(languageCode: 'zh', countryCode: 'TW')
;
const AppLocale({ const AppLocale({
required this.languageCode, required this.languageCode,
+3
View File
@@ -440,6 +440,9 @@ class Translations$sendPage$en {
// Translations // Translations
/// en: 'Calculating checksum ({curr} / {n})'
String calculatingChecksum({required Object curr, required Object n}) => 'Calculating checksum (${curr} / ${n})';
/// en: 'Waiting for response…' /// en: 'Waiting for response…'
String get waiting => 'Waiting for response…'; String get waiting => 'Waiting for response…';
@@ -18,6 +18,11 @@ class SendSessionState with SendSessionStateMappable implements SessionState {
final Device target; final Device target;
final Map<String, SendingFile> files; // file id as key final Map<String, SendingFile> files; // file id as key
/// Amount of files whose checksum has been calculated.
/// The checksums are calculated before the request is sent to the receiver,
/// so this is less than the file count while the session is being prepared.
final int hashedFileCount;
@override @override
final int? startTime; final int? startTime;
@@ -34,6 +39,7 @@ class SendSessionState with SendSessionStateMappable implements SessionState {
required this.status, required this.status,
required this.target, required this.target,
required this.files, required this.files,
required this.hashedFileCount,
required this.startTime, required this.startTime,
required this.endTime, required this.endTime,
required this.sendingTasks, required this.sendingTasks,
@@ -45,7 +51,7 @@ class SendSessionState with SendSessionStateMappable implements SessionState {
/// SendingFile. /// SendingFile.
@override @override
String toString() { String toString() {
return 'SendSessionState(sessionId: $sessionId, remoteSessionId: $remoteSessionId, background: $background, status: $status, target: $target, files: $files, startTime: $startTime, endTime: $endTime, sendingTasks: $sendingTasks, errorMessage: $errorMessage)'; return 'SendSessionState(sessionId: $sessionId, remoteSessionId: $remoteSessionId, background: $background, status: $status, target: $target, files: $files, hashedFileCount: $hashedFileCount, startTime: $startTime, endTime: $endTime, sendingTasks: $sendingTasks, errorMessage: $errorMessage)';
} }
} }
@@ -52,6 +52,11 @@ class SendSessionStateMapper extends ClassMapperBase<SendSessionState> {
static Map<String, SendingFile> _$files(SendSessionState v) => v.files; static Map<String, SendingFile> _$files(SendSessionState v) => v.files;
static const Field<SendSessionState, Map<String, SendingFile>> _f$files = static const Field<SendSessionState, Map<String, SendingFile>> _f$files =
Field('files', _$files); Field('files', _$files);
static int _$hashedFileCount(SendSessionState v) => v.hashedFileCount;
static const Field<SendSessionState, int> _f$hashedFileCount = Field(
'hashedFileCount',
_$hashedFileCount,
);
static int? _$startTime(SendSessionState v) => v.startTime; static int? _$startTime(SendSessionState v) => v.startTime;
static const Field<SendSessionState, int> _f$startTime = Field( static const Field<SendSessionState, int> _f$startTime = Field(
'startTime', 'startTime',
@@ -80,6 +85,7 @@ class SendSessionStateMapper extends ClassMapperBase<SendSessionState> {
#status: _f$status, #status: _f$status,
#target: _f$target, #target: _f$target,
#files: _f$files, #files: _f$files,
#hashedFileCount: _f$hashedFileCount,
#startTime: _f$startTime, #startTime: _f$startTime,
#endTime: _f$endTime, #endTime: _f$endTime,
#sendingTasks: _f$sendingTasks, #sendingTasks: _f$sendingTasks,
@@ -94,6 +100,7 @@ class SendSessionStateMapper extends ClassMapperBase<SendSessionState> {
status: data.dec(_f$status), status: data.dec(_f$status),
target: data.dec(_f$target), target: data.dec(_f$target),
files: data.dec(_f$files), files: data.dec(_f$files),
hashedFileCount: data.dec(_f$hashedFileCount),
startTime: data.dec(_f$startTime), startTime: data.dec(_f$startTime),
endTime: data.dec(_f$endTime), endTime: data.dec(_f$endTime),
sendingTasks: data.dec(_f$sendingTasks), sendingTasks: data.dec(_f$sendingTasks),
@@ -180,6 +187,7 @@ abstract class SendSessionStateCopyWith<$R, $In extends SendSessionState, $Out>
SessionStatus? status, SessionStatus? status,
Device? target, Device? target,
Map<String, SendingFile>? files, Map<String, SendingFile>? files,
int? hashedFileCount,
int? startTime, int? startTime,
int? endTime, int? endTime,
List<SendingTask>? sendingTasks, List<SendingTask>? sendingTasks,
@@ -230,6 +238,7 @@ class _SendSessionStateCopyWithImpl<$R, $Out>
SessionStatus? status, SessionStatus? status,
Device? target, Device? target,
Map<String, SendingFile>? files, Map<String, SendingFile>? files,
int? hashedFileCount,
Object? startTime = $none, Object? startTime = $none,
Object? endTime = $none, Object? endTime = $none,
Object? sendingTasks = $none, Object? sendingTasks = $none,
@@ -242,6 +251,7 @@ class _SendSessionStateCopyWithImpl<$R, $Out>
if (status != null) #status: status, if (status != null) #status: status,
if (target != null) #target: target, if (target != null) #target: target,
if (files != null) #files: files, if (files != null) #files: files,
if (hashedFileCount != null) #hashedFileCount: hashedFileCount,
if (startTime != $none) #startTime: startTime, if (startTime != $none) #startTime: startTime,
if (endTime != $none) #endTime: endTime, if (endTime != $none) #endTime: endTime,
if (sendingTasks != $none) #sendingTasks: sendingTasks, if (sendingTasks != $none) #sendingTasks: sendingTasks,
@@ -256,6 +266,7 @@ class _SendSessionStateCopyWithImpl<$R, $Out>
status: data.get(#status, or: $value.status), status: data.get(#status, or: $value.status),
target: data.get(#target, or: $value.target), target: data.get(#target, or: $value.target),
files: data.get(#files, or: $value.files), files: data.get(#files, or: $value.files),
hashedFileCount: data.get(#hashedFileCount, or: $value.hashedFileCount),
startTime: data.get(#startTime, or: $value.startTime), startTime: data.get(#startTime, or: $value.startTime),
endTime: data.get(#endTime, or: $value.endTime), endTime: data.get(#endTime, or: $value.endTime),
sendingTasks: data.get(#sendingTasks, or: $value.sendingTasks), sendingTasks: data.get(#sendingTasks, or: $value.sendingTasks),
+17 -1
View File
@@ -134,7 +134,23 @@ class _SendPageState extends State<SendPage> with Refena {
switch (sendState.status) { switch (sendState.status) {
SessionStatus.waiting => Padding( SessionStatus.waiting => Padding(
padding: const EdgeInsets.only(bottom: 20), padding: const EdgeInsets.only(bottom: 20),
child: Text(t.sendPage.waiting, textAlign: TextAlign.center), child: sendState.hashedFileCount < sendState.files.length
? Column(
children: [
Text(
t.sendPage.calculatingChecksum(curr: sendState.hashedFileCount, n: sendState.files.length),
textAlign: TextAlign.center,
),
const SizedBox(height: 15),
SizedBox(
width: 200,
child: LinearProgressIndicator(
value: sendState.hashedFileCount / sendState.files.length,
),
),
],
)
: Text(t.sendPage.waiting, textAlign: TextAlign.center),
), ),
SessionStatus.declined => Padding( SessionStatus.declined => Padding(
padding: const EdgeInsets.only(bottom: 20), padding: const EdgeInsets.only(bottom: 20),
+6 -1
View File
@@ -529,6 +529,7 @@ class _MultiSendDeviceListTile extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ref = context.ref; final ref = context.ref;
final session = ref.watch(sendProvider).values.firstWhereOrNull((s) => s.target.ip == device.ip); final session = ref.watch(sendProvider).values.firstWhereOrNull((s) => s.target.ip == device.ip);
final String? info;
final double? progress; final double? progress;
if (session != null) { if (session != null) {
final files = session.files.values.where((f) => f.token != null); final files = session.files.values.where((f) => f.token != null);
@@ -539,12 +540,16 @@ class _MultiSendDeviceListTile extends StatelessWidget {
); );
final totalBytes = files.fold<int>(0, (prev, curr) => prev + curr.file.size); final totalBytes = files.fold<int>(0, (prev, curr) => prev + curr.file.size);
progress = totalBytes == 0 ? 0 : currBytes / totalBytes; progress = totalBytes == 0 ? 0 : currBytes / totalBytes;
info = session.hashedFileCount < session.files.length
? t.sendPage.calculatingChecksum(curr: session.hashedFileCount, n: session.files.length)
: session.status.humanString;
} else { } else {
progress = null; progress = null;
info = null;
} }
return DeviceListTile( return DeviceListTile(
device: device, device: device,
info: session?.status.humanString, info: info,
progress: progress, progress: progress,
isFavorite: isFavorite, isFavorite: isFavorite,
nameOverride: nameOverride, nameOverride: nameOverride,
+113 -55
View File
@@ -20,8 +20,10 @@ import 'package:localsend_isolates/model/dto/file_dto.dart';
import 'package:localsend_isolates/model/file_status.dart'; import 'package:localsend_isolates/model/file_status.dart';
import 'package:localsend_isolates/model/file_type.dart'; import 'package:localsend_isolates/model/file_type.dart';
import 'package:localsend_isolates/model/session_status.dart'; import 'package:localsend_isolates/model/session_status.dart';
import 'package:localsend_isolates/rust/api/cancel.dart' as rust_cancel;
import 'package:localsend_isolates/rust/api/http.dart' as rust_http; import 'package:localsend_isolates/rust/api/http.dart' as rust_http;
import 'package:localsend_isolates/rust/api/model.dart' as rust_model; import 'package:localsend_isolates/rust/api/model.dart' as rust_model;
import 'package:localsend_isolates/util/file_hash.dart';
import 'package:localsend_isolates/util/rust.dart'; import 'package:localsend_isolates/util/rust.dart';
import 'package:localsend_isolates/util/sleep.dart'; import 'package:localsend_isolates/util/sleep.dart';
import 'package:localsend_isolates/util/transfer_notification.dart'; import 'package:localsend_isolates/util/transfer_notification.dart';
@@ -45,6 +47,10 @@ final sendProvider = NotifierProvider<SendNotifier, Map<String, SendSessionState
class SendNotifier extends Notifier<Map<String, SendSessionState>> { class SendNotifier extends Notifier<Map<String, SendSessionState>> {
SendNotifier(); SendNotifier();
/// Cancel tokens of the running checksum calculations.
/// Session ID -> Cancel token
final _hashCancelTokens = <String, rust_cancel.RsCancellationToken>{};
@override @override
Map<String, SendSessionState> init() { Map<String, SendSessionState> init() {
return {}; return {};
@@ -61,51 +67,109 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
final client = ref.read(httpProvider).v2; final client = ref.read(httpProvider).v2;
final sessionId = _uuid.v4(); final sessionId = _uuid.v4();
final requestState = SendSessionState( // The ids are assigned upfront, so the checksums calculated below
// can be mapped back to the corresponding file.
final selectedFiles = files.map((file) => (id: _uuid.v4(), file: file)).toList();
state = state.updateSession(
sessionId: sessionId, sessionId: sessionId,
remoteSessionId: null, state: (_) => SendSessionState(
background: background, sessionId: sessionId,
status: SessionStatus.waiting, remoteSessionId: null,
target: target, background: background,
files: Map.fromEntries( status: SessionStatus.waiting,
await Future.wait( target: target,
files.map((file) async { files: {
final id = _uuid.v4(); for (final (:id, :file) in selectedFiles)
return MapEntry( id: SendingFile(
id, file: FileDto(
SendingFile( id: id,
file: FileDto( fileName: file.name,
id: id, size: file.size,
fileName: file.name, fileType: file.fileType,
size: file.size, hash: null,
fileType: file.fileType, // calculated below
hash: null, preview: files.length == 1 && files.first.fileType == FileType.text && files.first.bytes != null
preview: files.length == 1 && files.first.fileType == FileType.text && files.first.bytes != null ? utf8.decode(files.first.bytes!) // send simple message by embedding it into the preview
? utf8.decode(files.first.bytes!) // send simple message by embedding it into the preview : null,
: null, metadata: file.lastModified != null || file.lastAccessed != null
metadata: file.lastModified != null || file.lastAccessed != null ? FileMetadata(
? FileMetadata( lastModified: file.lastModified,
lastModified: file.lastModified, lastAccessed: file.lastAccessed,
lastAccessed: file.lastAccessed, )
) : null,
: null,
),
status: FileStatus.queue,
token: null,
thumbnail: file.thumbnail,
asset: file.asset,
path: file.path,
bytes: file.bytes,
errorMessage: null,
), ),
); status: FileStatus.queue,
}), token: null,
), thumbnail: file.thumbnail,
asset: file.asset,
path: file.path,
bytes: file.bytes,
errorMessage: null,
),
},
hashedFileCount: 0,
startTime: null,
endTime: null,
sendingTasks: [],
errorMessage: null,
), ),
startTime: null, );
endTime: null,
sendingTasks: [], if (!background) {
errorMessage: null, // ignore: use_build_context_synchronously, unawaited_futures
Routerino.context.push(
() => SendPage(showAppBar: false, closeSessionOnClose: true, sessionId: sessionId),
transition: RouterinoTransition.fade(),
);
}
// Calculate the checksums which are part of the request.
// The files are read and hashed in Rust, one file after another.
final hashCancelToken = rust_cancel.createCancellationToken();
_hashCancelTokens[sessionId] = hashCancelToken;
final hashes = <String, String>{};
try {
for (final (:id, :file) in selectedFiles) {
try {
hashes[id] = await calculateFileHash(path: file.path, bytes: file.bytes, cancelToken: hashCancelToken);
} catch (e) {
if (state[sessionId] != null) {
// Sending the checksum is optional, so a file that cannot be read
// here still gets a chance to be sent.
// Errors caused by the cancellation are not logged.
_logger.warning('Could not calculate the checksum of ${file.name}', e);
}
}
if (state[sessionId] == null) {
// session has been canceled while calculating the checksums
return;
}
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.copyWith(hashedFileCount: s.hashedFileCount + 1),
);
}
} finally {
_hashCancelTokens.remove(sessionId);
}
final hashedState = state[sessionId];
if (hashedState == null) {
// session has been canceled while calculating the checksums
return;
}
final requestState = hashedState.copyWith(
files: hashedState.files.map(
(id, sendingFile) => MapEntry(id, sendingFile.copyWith(file: sendingFile.file.withHash(hashes[id]))),
),
);
state = state.updateSession(
sessionId: sessionId,
state: (_) => requestState,
); );
final originDevice = ref.read(deviceFullInfoProvider); final originDevice = ref.read(deviceFullInfoProvider);
@@ -125,19 +189,6 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
}, },
); );
state = state.updateSession(
sessionId: sessionId,
state: (_) => requestState,
);
if (!background) {
// ignore: use_build_context_synchronously, unawaited_futures
Routerino.context.push(
() => SendPage(showAppBar: false, closeSessionOnClose: true, sessionId: sessionId),
transition: RouterinoTransition.fade(),
);
}
rust_http.PrepareUploadResult? response; rust_http.PrepareUploadResult? response;
bool invalidPin; bool invalidPin;
bool pinFirstAttempt = true; bool pinFirstAttempt = true;
@@ -617,6 +668,8 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
} }
void _cancelRunningRequests(SendSessionState state) { void _cancelRunningRequests(SendSessionState state) {
_hashCancelTokens.remove(state.sessionId)?.cancel();
for (final task in state.sendingTasks ?? <SendingTask>[]) { for (final task in state.sendingTasks ?? <SendingTask>[]) {
ref ref
.redux(parentIsolateProvider) .redux(parentIsolateProvider)
@@ -635,6 +688,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
return; return;
} }
TransferNotification.stop(sessionId); TransferNotification.stop(sessionId);
_hashCancelTokens.remove(sessionId)?.cancel();
state = state.removeSession(ref, sessionId); state = state.removeSession(ref, sessionId);
if (sessionState.status == SessionStatus.finished && ref.read(settingsProvider).sendMode == SendMode.single) { if (sessionState.status == SessionStatus.finished && ref.read(settingsProvider).sendMode == SendMode.single) {
// clear selected files // clear selected files
@@ -646,6 +700,10 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
for (final sessionId in state.keys) { for (final sessionId in state.keys) {
TransferNotification.stop(sessionId); TransferNotification.stop(sessionId);
} }
for (final cancelToken in _hashCancelTokens.values) {
cancelToken.cancel();
}
_hashCancelTokens.clear();
state = {}; state = {};
ref.notifier(progressProvider).removeAllSessions(); ref.notifier(progressProvider).removeAllSessions();
} }
+1 -1
View File
@@ -40,7 +40,7 @@ x509-parser = { version = "0.18.0", features = ["verify"], optional = true }
[features] [features]
default = [] default = []
crypto = ["ed25519-dalek", "rsa", "sha2"] crypto = ["ed25519-dalek", "rsa", "sha2", "tokio-util"]
http = ["crypto", "form_urlencoded", "http-body-util", "hyper", "hyper-util", "pem", "percent-encoding", "reqwest", "rustls", "socket2", "tokio-rustls", "tokio-util", "x509-parser"] http = ["crypto", "form_urlencoded", "http-body-util", "hyper", "hyper-util", "pem", "percent-encoding", "reqwest", "rustls", "socket2", "tokio-rustls", "tokio-util", "x509-parser"]
webrtc-signaling = ["tokio-tungstenite"] webrtc-signaling = ["tokio-tungstenite"]
webrtc = ["crypto", "flate2", "dep:webrtc", "webrtc-signaling", "x509-parser"] webrtc = ["crypto", "flate2", "dep:webrtc", "webrtc-signaling", "x509-parser"]
+85
View File
@@ -1,7 +1,92 @@
use crate::model::transfer::FileContent;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio_util::sync::CancellationToken;
/// Buffer size used when hashing a file chunk by chunk.
const HASH_BUFFER_SIZE: usize = 64 * 1024;
#[derive(Debug, thiserror::Error)]
pub enum HashError {
#[error("Failed to read the file: {0}")]
Io(#[from] std::io::Error),
#[error("Hashing has been cancelled")]
Cancelled,
}
pub fn sha256(data: &[u8]) -> Vec<u8> { pub fn sha256(data: &[u8]) -> Vec<u8> {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(data); hasher.update(data);
hasher.finalize().to_vec() hasher.finalize().to_vec()
} }
/// Computes the SHA-256 checksum of `data`, encoded as lowercase hex.
pub fn sha256_hex(data: &[u8]) -> String {
to_hex(&sha256(data))
}
/// Computes the SHA-256 checksum of a file's content, encoded as lowercase hex.
pub async fn sha256_file_content(
content: FileContent,
cancel_token: &CancellationToken,
) -> Result<String, HashError> {
let mut hasher = Sha256::new();
match content {
FileContent::Stream(mut receiver) => loop {
let chunk = tokio::select! {
biased;
_ = cancel_token.cancelled() => return Err(HashError::Cancelled),
chunk = receiver.recv() => chunk,
};
match chunk {
Some(chunk) => hasher.update(&chunk),
None => break,
}
},
FileContent::Path(path) => {
tracing::info!("Hashing file content from path: {}", path.display());
let file = tokio::fs::File::open(&path).await?;
read_and_hash_from_file(&mut hasher, file, cancel_token).await?;
}
#[cfg(target_os = "android")]
FileContent::Fd(fd) => {
use std::os::fd::FromRawFd;
tracing::info!("Hashing file content from file descriptor: {fd}");
// SAFETY: the descriptor is owned by this call; wrapping it in a File
// transfers that ownership so it is closed once hashing finishes.
let std_file = unsafe { std::fs::File::from_raw_fd(fd) };
let file = tokio::fs::File::from_std(std_file);
read_and_hash_from_file(&mut hasher, file, cancel_token).await?;
}
}
Ok(to_hex(&hasher.finalize()))
}
/// Reads `file` to EOF, feeding every chunk into `hasher`.
async fn read_and_hash_from_file(
hasher: &mut Sha256,
mut file: tokio::fs::File,
cancel_token: &CancellationToken,
) -> Result<(), HashError> {
use tokio::io::AsyncReadExt;
let mut buffer = vec![0u8; HASH_BUFFER_SIZE];
loop {
let read = tokio::select! {
biased;
_ = cancel_token.cancelled() => return Err(HashError::Cancelled),
read = file.read(&mut buffer) => read?,
};
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(())
}
fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
+94
View File
@@ -0,0 +1,94 @@
#![cfg(feature = "crypto")]
use localsend::crypto::hash::{sha256_file_content, sha256_hex, HashError};
use localsend::model::transfer::FileContent;
use tokio_util::sync::CancellationToken;
/// SHA-256 of "hello world".
const HELLO_WORLD_HASH: &str = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
#[tokio::test]
async fn hash_file_from_path() {
let path = std::env::temp_dir().join(format!("localsend-hash-{}", uuid::Uuid::new_v4()));
tokio::fs::write(&path, b"hello world").await.unwrap();
let hash = sha256_file_content(FileContent::Path(path.clone()), &CancellationToken::new())
.await
.unwrap();
assert_eq!(hash, HELLO_WORLD_HASH);
tokio::fs::remove_file(&path).await.unwrap();
}
/// A file larger than the internal buffer must be hashed across multiple reads.
#[tokio::test]
async fn hash_large_file_from_path() {
let content: Vec<u8> = (0..500_000).map(|i| (i % 251) as u8).collect();
let path = std::env::temp_dir().join(format!("localsend-hash-{}", uuid::Uuid::new_v4()));
tokio::fs::write(&path, &content).await.unwrap();
let hash = sha256_file_content(FileContent::Path(path.clone()), &CancellationToken::new())
.await
.unwrap();
assert_eq!(hash, sha256_hex(&content));
tokio::fs::remove_file(&path).await.unwrap();
}
/// Hashing must stop when the token is cancelled while the file is being read.
#[tokio::test]
async fn hash_cancelled_while_reading() {
let (tx, rx) = tokio::sync::mpsc::channel(1);
let cancel_token = CancellationToken::new();
let handle = tokio::spawn({
let token = cancel_token.clone();
async move { sha256_file_content(FileContent::Stream(rx), &token).await }
});
// The sender stays alive, so hashing only ends because of the cancellation.
tx.send(bytes::Bytes::from_static(b"hello ")).await.unwrap();
cancel_token.cancel();
let result = handle.await.unwrap();
assert!(matches!(result, Err(HashError::Cancelled)));
}
/// A token that is already cancelled must not start reading at all.
#[tokio::test]
async fn hash_cancelled_before_start() {
let path = std::env::temp_dir().join(format!("localsend-hash-{}", uuid::Uuid::new_v4()));
tokio::fs::write(&path, b"hello world").await.unwrap();
let cancel_token = CancellationToken::new();
cancel_token.cancel();
let result = sha256_file_content(FileContent::Path(path.clone()), &cancel_token).await;
assert!(matches!(result, Err(HashError::Cancelled)));
tokio::fs::remove_file(&path).await.unwrap();
}
#[tokio::test]
async fn hash_missing_file_fails() {
let path = std::env::temp_dir().join(format!("localsend-hash-{}", uuid::Uuid::new_v4()));
let result = sha256_file_content(FileContent::Path(path), &CancellationToken::new()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn hash_stream() {
let (tx, rx) = tokio::sync::mpsc::channel(4);
tokio::spawn(async move {
tx.send(bytes::Bytes::from_static(b"hello ")).await.unwrap();
tx.send(bytes::Bytes::from_static(b"world")).await.unwrap();
});
let hash = sha256_file_content(FileContent::Stream(rx), &CancellationToken::new())
.await
.unwrap();
assert_eq!(hash, HELLO_WORLD_HASH);
}
@@ -41,6 +41,19 @@ class FileDto {
String lookupMime() => lookupMimeType(fileName) ?? 'application/octet-stream'; String lookupMime() => lookupMimeType(fileName) ?? 'application/octet-stream';
/// Returns a copy of this DTO with the given [hash].
FileDto withHash(String? hash) {
return FileDto(
id: id,
fileName: fileName,
size: size,
fileType: fileType,
hash: hash,
preview: preview,
metadata: metadata,
);
}
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
@@ -0,0 +1,14 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:localsend_isolates/rust/frb_generated.dart';
RsCancellationToken createCancellationToken() => RustLib.instance.api.crateApiCancelCreateCancellationToken();
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<RsCancellationToken>>
abstract class RsCancellationToken implements RustOpaqueInterface {
void cancel();
}
@@ -4,6 +4,7 @@
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:localsend_isolates/rust/api/cancel.dart';
import 'package:localsend_isolates/rust/frb_generated.dart'; import 'package:localsend_isolates/rust/frb_generated.dart';
Future<void> verifyCert({required String cert, required String publicKey}) => Future<void> verifyCert({required String cert, required String publicKey}) =>
@@ -11,6 +12,18 @@ Future<void> verifyCert({required String cert, required String publicKey}) =>
Future<KeyPair> generateKeyPair() => RustLib.instance.api.crateApiCryptoGenerateKeyPair(); Future<KeyPair> generateKeyPair() => RustLib.instance.api.crateApiCryptoGenerateKeyPair();
/// Computes the SHA-256 checksum of a file, encoded as lowercase hex.
///
/// The file is read chunk by chunk, so it is never fully loaded into memory.
/// Cancelling [cancel_token] aborts the read, so hashing a large file does not
/// have to be waited out.
///
/// Exactly one content source must be provided:
/// a [path] to a regular file, a [file_descriptor] (Android only), or [bytes]
/// for a file that only lives in memory.
Future<String> hashFile({String? path, int? fileDescriptor, Uint8List? bytes, required RsCancellationToken cancelToken}) =>
RustLib.instance.api.crateApiCryptoHashFile(path: path, fileDescriptor: fileDescriptor, bytes: bytes, cancelToken: cancelToken);
class KeyPair { class KeyPair {
final String privateKey; final String privateKey;
final String publicKey; final String publicKey;
@@ -5,6 +5,7 @@
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:freezed_annotation/freezed_annotation.dart' hide protected; import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
import 'package:localsend_isolates/rust/api/cancel.dart';
import 'package:localsend_isolates/rust/api/model.dart'; import 'package:localsend_isolates/rust/api/model.dart';
import 'package:localsend_isolates/rust/api/stream.dart'; import 'package:localsend_isolates/rust/api/stream.dart';
import 'package:localsend_isolates/rust/frb_generated.dart'; import 'package:localsend_isolates/rust/frb_generated.dart';
@@ -17,13 +18,6 @@ part 'http.freezed.dart';
RsHttpClient createClient({required String privateKey, required String cert, required LsHttpClientVersion version, int? timeoutMs}) => RsHttpClient createClient({required String privateKey, required String cert, required LsHttpClientVersion version, int? timeoutMs}) =>
RustLib.instance.api.crateApiHttpCreateClient(privateKey: privateKey, cert: cert, version: version, timeoutMs: timeoutMs); RustLib.instance.api.crateApiHttpCreateClient(privateKey: privateKey, cert: cert, version: version, timeoutMs: timeoutMs);
RsCancellationToken createCancellationToken() => RustLib.instance.api.crateApiHttpCreateCancellationToken();
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<RsCancellationToken>>
abstract class RsCancellationToken implements RustOpaqueInterface {
void cancel();
}
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<RsHttpClient>> // Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<RsHttpClient>>
abstract class RsHttpClient implements RustOpaqueInterface { abstract class RsHttpClient implements RustOpaqueInterface {
Future<void> cancel({required ProtocolType protocol, required String ip, required int port, required String sessionId}); Future<void> cancel({required ProtocolType protocol, required String ip, required int port, required String sessionId});
@@ -7,6 +7,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:localsend_isolates/rust/api/cancel.dart';
import 'package:localsend_isolates/rust/api/crypto.dart'; import 'package:localsend_isolates/rust/api/crypto.dart';
import 'package:localsend_isolates/rust/api/http.dart'; import 'package:localsend_isolates/rust/api/http.dart';
import 'package:localsend_isolates/rust/api/logging.dart'; import 'package:localsend_isolates/rust/api/logging.dart';
@@ -72,7 +73,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0'; String get codegenVersion => '2.12.0';
@override @override
int get rustContentHash => 1916764705; int get rustContentHash => 1193619754;
static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig(
stem: 'rust_lib_localsend_app', stem: 'rust_lib_localsend_app',
@@ -108,7 +109,7 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateApiWebrtcLsSignalingConnectionUpdateInfo({required LsSignalingConnection that, required ClientInfoWithoutId info}); Future<void> crateApiWebrtcLsSignalingConnectionUpdateInfo({required LsSignalingConnection that, required ClientInfoWithoutId info});
void crateApiHttpRsCancellationTokenCancel({required RsCancellationToken that}); void crateApiCancelRsCancellationTokenCancel({required RsCancellationToken that});
Future<void> crateApiHttpRsHttpClientCancel({ Future<void> crateApiHttpRsHttpClientCancel({
required RsHttpClient that, required RsHttpClient that,
@@ -222,7 +223,7 @@ abstract class RustLibApi extends BaseApi {
required FutureOr<void> Function(LsSignalingConnection) onConnection, required FutureOr<void> Function(LsSignalingConnection) onConnection,
}); });
RsCancellationToken crateApiHttpCreateCancellationToken(); RsCancellationToken crateApiCancelCreateCancellationToken();
RsHttpClient crateApiHttpCreateClient({required String privateKey, required String cert, required LsHttpClientVersion version, int? timeoutMs}); RsHttpClient crateApiHttpCreateClient({required String privateKey, required String cert, required LsHttpClientVersion version, int? timeoutMs});
@@ -232,6 +233,8 @@ abstract class RustLibApi extends BaseApi {
Future<KeyPair> crateApiCryptoGenerateKeyPair(); Future<KeyPair> crateApiCryptoGenerateKeyPair();
Future<String> crateApiCryptoHashFile({String? path, int? fileDescriptor, Uint8List? bytes, required RsCancellationToken cancelToken});
Future<RsHttpServer> crateApiServerStartServer({ Future<RsHttpServer> crateApiServerStartServer({
required int port, required int port,
TlsConfig? tls, TlsConfig? tls,
@@ -470,7 +473,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
); );
@override @override
void crateApiHttpRsCancellationTokenCancel({required RsCancellationToken that}) { void crateApiCancelRsCancellationTokenCancel({required RsCancellationToken that}) {
return handler.executeSync( return handler.executeSync(
SyncTask( SyncTask(
callFfi: () { callFfi: () {
@@ -482,14 +485,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
decodeSuccessData: sse_decode_unit, decodeSuccessData: sse_decode_unit,
decodeErrorData: null, decodeErrorData: null,
), ),
constMeta: kCrateApiHttpRsCancellationTokenCancelConstMeta, constMeta: kCrateApiCancelRsCancellationTokenCancelConstMeta,
argValues: [that], argValues: [that],
apiImpl: this, apiImpl: this,
), ),
); );
} }
TaskConstMeta get kCrateApiHttpRsCancellationTokenCancelConstMeta => const TaskConstMeta( TaskConstMeta get kCrateApiCancelRsCancellationTokenCancelConstMeta => const TaskConstMeta(
debugName: 'RsCancellationToken_cancel', debugName: 'RsCancellationToken_cancel',
argNames: ['that'], argNames: ['that'],
); );
@@ -1420,7 +1423,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
); );
@override @override
RsCancellationToken crateApiHttpCreateCancellationToken() { RsCancellationToken crateApiCancelCreateCancellationToken() {
return handler.executeSync( return handler.executeSync(
SyncTask( SyncTask(
callFfi: () { callFfi: () {
@@ -1431,14 +1434,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
decodeSuccessData: sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsCancellationToken, decodeSuccessData: sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsCancellationToken,
decodeErrorData: null, decodeErrorData: null,
), ),
constMeta: kCrateApiHttpCreateCancellationTokenConstMeta, constMeta: kCrateApiCancelCreateCancellationTokenConstMeta,
argValues: [], argValues: [],
apiImpl: this, apiImpl: this,
), ),
); );
} }
TaskConstMeta get kCrateApiHttpCreateCancellationTokenConstMeta => const TaskConstMeta( TaskConstMeta get kCrateApiCancelCreateCancellationTokenConstMeta => const TaskConstMeta(
debugName: 'create_cancellation_token', debugName: 'create_cancellation_token',
argNames: [], argNames: [],
); );
@@ -1544,6 +1547,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: [], argNames: [],
); );
@override
Future<String> crateApiCryptoHashFile({String? path, int? fileDescriptor, Uint8List? bytes, required RsCancellationToken cancelToken}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_opt_String(path, serializer);
sse_encode_opt_box_autoadd_i_32(fileDescriptor, serializer);
sse_encode_opt_list_prim_u_8_strict(bytes, serializer);
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsCancellationToken(cancelToken, serializer);
pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 42, port: port_);
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
decodeErrorData: sse_decode_AnyhowException,
),
constMeta: kCrateApiCryptoHashFileConstMeta,
argValues: [path, fileDescriptor, bytes, cancelToken],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiCryptoHashFileConstMeta => const TaskConstMeta(
debugName: 'hash_file',
argNames: ['path', 'fileDescriptor', 'bytes', 'cancelToken'],
);
@override @override
Future<RsHttpServer> crateApiServerStartServer({ Future<RsHttpServer> crateApiServerStartServer({
required int port, required int port,
@@ -1571,7 +1602,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_opt_String(pin, serializer); sse_encode_opt_String(pin, serializer);
sse_encode_opt_box_autoadd_web_send_params(webSend, serializer); sse_encode_opt_box_autoadd_web_send_params(webSend, serializer);
sse_encode_opt_String(showToken, serializer); sse_encode_opt_String(showToken, serializer);
pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 42, port: port_); pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 43, port: port_);
}, },
codec: SseCodec( codec: SseCodec(
decodeSuccessData: sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer, decodeSuccessData: sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer,
@@ -1597,7 +1628,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(cert, serializer); sse_encode_String(cert, serializer);
sse_encode_String(publicKey, serializer); sse_encode_String(publicKey, serializer);
pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 43, port: port_); pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 44, port: port_);
}, },
codec: SseCodec( codec: SseCodec(
decodeSuccessData: sse_decode_unit, decodeSuccessData: sse_decode_unit,
@@ -2327,6 +2358,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return raw == null ? null : dco_decode_list_String(raw); return raw == null ? null : dco_decode_list_String(raw);
} }
@protected
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw == null ? null : dco_decode_list_prim_u_8_strict(raw);
}
@protected @protected
PinConfig dco_decode_pin_config(dynamic raw) { PinConfig dco_decode_pin_config(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
@@ -3498,6 +3535,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
} }
} }
@protected
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
if (sse_decode_bool(deserializer)) {
return (sse_decode_list_prim_u_8_strict(deserializer));
} else {
return null;
}
}
@protected @protected
PinConfig sse_decode_pin_config(SseDeserializer deserializer) { PinConfig sse_decode_pin_config(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
@@ -4697,6 +4745,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
} }
} }
@protected
void sse_encode_opt_list_prim_u_8_strict(Uint8List? self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_bool(self != null, serializer);
if (self != null) {
sse_encode_list_prim_u_8_strict(self, serializer);
}
}
@protected @protected
void sse_encode_pin_config(PinConfig self, SseSerializer serializer) { void sse_encode_pin_config(PinConfig self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
@@ -5142,7 +5200,7 @@ class RsCancellationTokenImpl extends RustOpaque implements RsCancellationToken
rustArcDecrementStrongCountPtr: RustLib.instance.api.rust_arc_decrement_strong_count_RsCancellationTokenPtr, rustArcDecrementStrongCountPtr: RustLib.instance.api.rust_arc_decrement_strong_count_RsCancellationTokenPtr,
); );
void cancel() => RustLib.instance.api.crateApiHttpRsCancellationTokenCancel( void cancel() => RustLib.instance.api.crateApiCancelRsCancellationTokenCancel(
that: this, that: this,
); );
} }
@@ -8,6 +8,7 @@ import 'dart:convert';
import 'dart:ffi' as ffi; import 'dart:ffi' as ffi;
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart';
import 'package:localsend_isolates/rust/api/cancel.dart';
import 'package:localsend_isolates/rust/api/crypto.dart'; import 'package:localsend_isolates/rust/api/crypto.dart';
import 'package:localsend_isolates/rust/api/http.dart'; import 'package:localsend_isolates/rust/api/http.dart';
import 'package:localsend_isolates/rust/api/logging.dart'; import 'package:localsend_isolates/rust/api/logging.dart';
@@ -349,6 +350,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
List<String>? dco_decode_opt_list_String(dynamic raw); List<String>? dco_decode_opt_list_String(dynamic raw);
@protected
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw);
@protected @protected
PinConfig dco_decode_pin_config(dynamic raw); PinConfig dco_decode_pin_config(dynamic raw);
@@ -758,6 +762,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
List<String>? sse_decode_opt_list_String(SseDeserializer deserializer); List<String>? sse_decode_opt_list_String(SseDeserializer deserializer);
@protected
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer);
@protected @protected
PinConfig sse_decode_pin_config(SseDeserializer deserializer); PinConfig sse_decode_pin_config(SseDeserializer deserializer);
@@ -1212,6 +1219,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_opt_list_String(List<String>? self, SseSerializer serializer); void sse_encode_opt_list_String(List<String>? self, SseSerializer serializer);
@protected
void sse_encode_opt_list_prim_u_8_strict(Uint8List? self, SseSerializer serializer);
@protected @protected
void sse_encode_pin_config(PinConfig self, SseSerializer serializer); void sse_encode_pin_config(PinConfig self, SseSerializer serializer);
@@ -10,6 +10,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart';
import 'package:localsend_isolates/rust/api/cancel.dart';
import 'package:localsend_isolates/rust/api/crypto.dart'; import 'package:localsend_isolates/rust/api/crypto.dart';
import 'package:localsend_isolates/rust/api/http.dart'; import 'package:localsend_isolates/rust/api/http.dart';
import 'package:localsend_isolates/rust/api/logging.dart'; import 'package:localsend_isolates/rust/api/logging.dart';
@@ -351,6 +352,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
List<String>? dco_decode_opt_list_String(dynamic raw); List<String>? dco_decode_opt_list_String(dynamic raw);
@protected
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw);
@protected @protected
PinConfig dco_decode_pin_config(dynamic raw); PinConfig dco_decode_pin_config(dynamic raw);
@@ -760,6 +764,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
List<String>? sse_decode_opt_list_String(SseDeserializer deserializer); List<String>? sse_decode_opt_list_String(SseDeserializer deserializer);
@protected
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer);
@protected @protected
PinConfig sse_decode_pin_config(SseDeserializer deserializer); PinConfig sse_decode_pin_config(SseDeserializer deserializer);
@@ -1214,6 +1221,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_opt_list_String(List<String>? self, SseSerializer serializer); void sse_encode_opt_list_String(List<String>? self, SseSerializer serializer);
@protected
void sse_encode_opt_list_prim_u_8_strict(Uint8List? self, SseSerializer serializer);
@protected @protected
void sse_encode_pin_config(PinConfig self, SseSerializer serializer); void sse_encode_pin_config(PinConfig self, SseSerializer serializer);
@@ -1,7 +1,7 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:localsend_isolates/isolate.dart'; import 'package:localsend_isolates/isolate.dart';
import 'package:localsend_isolates/model/device.dart'; import 'package:localsend_isolates/model/device.dart';
import 'package:localsend_isolates/rust/api/http.dart'; import 'package:localsend_isolates/rust/api/cancel.dart';
import 'package:localsend_isolates/src/isolate/child/main.dart'; import 'package:localsend_isolates/src/isolate/child/main.dart';
import 'package:localsend_isolates/src/isolate/dto/send_to_isolate_data.dart'; import 'package:localsend_isolates/src/isolate/dto/send_to_isolate_data.dart';
import 'package:localsend_isolates/src/task/upload/http_upload.dart'; import 'package:localsend_isolates/src/task/upload/http_upload.dart';
@@ -1,4 +1,5 @@
import 'package:localsend_isolates/model/device.dart'; import 'package:localsend_isolates/model/device.dart';
import 'package:localsend_isolates/rust/api/cancel.dart';
import 'package:localsend_isolates/rust/api/http.dart'; import 'package:localsend_isolates/rust/api/http.dart';
import 'package:localsend_isolates/rust/api/stream.dart'; import 'package:localsend_isolates/rust/api/stream.dart';
import 'package:localsend_isolates/src/isolate/child/http_provider.dart'; import 'package:localsend_isolates/src/isolate/child/http_provider.dart';
@@ -0,0 +1,35 @@
import 'dart:typed_data';
import 'package:localsend_isolates/rust/api/cancel.dart';
import 'package:localsend_isolates/rust/api/crypto.dart' as rust_crypto;
import 'package:localsend_isolates/util/android_channel.dart';
/// Calculates the SHA-256 checksum (lowercase hex) of a single file.
///
/// The file is read and hashed in Rust, so this only occupies the Dart isolate
/// while waiting for the result.
/// Cancelling [cancelToken] aborts the calculation and throws.
Future<String> calculateFileHash({
required String? path,
required List<int>? bytes,
required RsCancellationToken cancelToken,
}) async {
if (path != null) {
if (path.startsWith('content://')) {
// Android SAF files can only be read through a file descriptor.
// The descriptor is closed by Rust after the file has been read.
final fileDescriptor = await getFileDescriptorAndroid(uri: path);
return await rust_crypto.hashFile(fileDescriptor: fileDescriptor, cancelToken: cancelToken);
}
return await rust_crypto.hashFile(path: path, cancelToken: cancelToken);
}
if (bytes != null) {
return await rust_crypto.hashFile(
bytes: bytes is Uint8List ? bytes : Uint8List.fromList(bytes),
cancelToken: cancelToken,
);
}
throw ArgumentError('Either path or bytes must be provided');
}
@@ -0,0 +1,19 @@
use flutter_rust_bridge::frb;
pub struct RsCancellationToken {
pub(crate) inner: tokio_util::sync::CancellationToken,
}
#[frb(sync)]
pub fn create_cancellation_token() -> RsCancellationToken {
RsCancellationToken {
inner: tokio_util::sync::CancellationToken::new(),
}
}
impl RsCancellationToken {
#[frb(sync)]
pub fn cancel(&self) {
self.inner.cancel();
}
}
@@ -1,3 +1,5 @@
use crate::api::cancel::RsCancellationToken;
pub fn verify_cert(cert: String, public_key: String) -> anyhow::Result<()> { pub fn verify_cert(cert: String, public_key: String) -> anyhow::Result<()> {
localsend::crypto::cert::verify_cert_from_pem(cert, Some(&public_key)) localsend::crypto::cert::verify_cert_from_pem(cert, Some(&public_key))
} }
@@ -17,3 +19,38 @@ pub struct KeyPair {
pub private_key: String, pub private_key: String,
pub public_key: String, pub public_key: String,
} }
/// Computes the SHA-256 checksum of a file, encoded as lowercase hex.
///
/// The file is read chunk by chunk, so it is never fully loaded into memory.
/// Cancelling [cancel_token] aborts the read, so hashing a large file does not
/// have to be waited out.
///
/// Exactly one content source must be provided:
/// a [path] to a regular file, a [file_descriptor] (Android only), or [bytes]
/// for a file that only lives in memory.
pub async fn hash_file(
path: Option<String>,
file_descriptor: Option<i32>,
bytes: Option<Vec<u8>>,
cancel_token: &RsCancellationToken,
) -> anyhow::Result<String> {
let content = match (path, file_descriptor, bytes) {
(Some(path), None, None) => localsend::model::transfer::FileContent::Path(path.into()),
(None, Some(file_descriptor), None) => {
#[cfg(target_os = "android")]
{
localsend::model::transfer::FileContent::Fd(file_descriptor)
}
#[cfg(not(target_os = "android"))]
{
let _ = file_descriptor;
anyhow::bail!("File descriptors are only supported on Android");
}
}
(None, None, Some(bytes)) => return Ok(localsend::crypto::hash::sha256_hex(&bytes)),
_ => anyhow::bail!("Exactly one content source must be provided"),
};
Ok(localsend::crypto::hash::sha256_file_content(content, &cancel_token.inner).await?)
}
@@ -1,3 +1,4 @@
use crate::api::cancel::RsCancellationToken;
use crate::api::stream; use crate::api::stream;
use crate::frb_generated::StreamSink; use crate::frb_generated::StreamSink;
use flutter_rust_bridge::frb; use flutter_rust_bridge::frb;
@@ -29,24 +30,6 @@ pub fn create_client(
Ok(RsHttpClient { inner }) Ok(RsHttpClient { inner })
} }
pub struct RsCancellationToken {
inner: tokio_util::sync::CancellationToken,
}
#[frb(sync)]
pub fn create_cancellation_token() -> RsCancellationToken {
RsCancellationToken {
inner: tokio_util::sync::CancellationToken::new(),
}
}
impl RsCancellationToken {
#[frb(sync)]
pub fn cancel(&self) {
self.inner.cancel();
}
}
impl RsHttpClient { impl RsHttpClient {
pub async fn register( pub async fn register(
&self, &self,
@@ -1,3 +1,4 @@
pub mod cancel;
pub mod crypto; pub mod crypto;
pub mod http; pub mod http;
pub mod logging; pub mod logging;
@@ -26,6 +26,7 @@
// Section: imports // Section: imports
use crate::api::cancel::*;
use crate::api::http::*; use crate::api::http::*;
use crate::api::server::*; use crate::api::server::*;
use crate::api::stream::*; use crate::api::stream::*;
@@ -42,7 +43,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi, default_rust_auto_opaque = RustAutoOpaqueMoi,
); );
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1916764705; pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1193619754;
// Section: executor // Section: executor
@@ -358,7 +359,7 @@ fn wire__crate__api__webrtc__LsSignalingConnection_update_info_impl(
}, },
) )
} }
fn wire__crate__api__http__RsCancellationToken_cancel_impl( fn wire__crate__api__cancel__RsCancellationToken_cancel_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32, rust_vec_len_: i32,
data_len_: i32, data_len_: i32,
@@ -399,7 +400,7 @@ fn wire__crate__api__http__RsCancellationToken_cancel_impl(
} }
let api_that_guard = api_that_guard.unwrap(); let api_that_guard = api_that_guard.unwrap();
let output_ok = Result::<_, ()>::Ok({ let output_ok = Result::<_, ()>::Ok({
crate::api::http::RsCancellationToken::cancel(&*api_that_guard); crate::api::cancel::RsCancellationToken::cancel(&*api_that_guard);
})?; })?;
Ok(output_ok) Ok(output_ok)
})()) })())
@@ -2272,7 +2273,7 @@ let api_on_connection = decode_DartFn_Inputs_Auto_Owned_RustOpaque_flutter_rust_
})().await) })().await)
} }) } })
} }
fn wire__crate__api__http__create_cancellation_token_impl( fn wire__crate__api__cancel__create_cancellation_token_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32, rust_vec_len_: i32,
data_len_: i32, data_len_: i32,
@@ -2295,7 +2296,8 @@ fn wire__crate__api__http__create_cancellation_token_impl(
flutter_rust_bridge::for_generated::SseDeserializer::new(message); flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end(); deserializer.end();
transform_result_sse::<_, ()>((move || { transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok(crate::api::http::create_cancellation_token())?; let output_ok =
Result::<_, ()>::Ok(crate::api::cancel::create_cancellation_token())?;
Ok(output_ok) Ok(output_ok)
})()) })())
}, },
@@ -2440,6 +2442,72 @@ fn wire__crate__api__crypto__generate_key_pair_impl(
}, },
) )
} }
fn wire__crate__api__crypto__hash_file_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "hash_file",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_path = <Option<String>>::sse_decode(&mut deserializer);
let api_file_descriptor = <Option<i32>>::sse_decode(&mut deserializer);
let api_bytes = <Option<Vec<u8>>>::sse_decode(&mut deserializer);
let api_cancel_token = <RustOpaqueMoi<
flutter_rust_bridge::for_generated::RustAutoOpaqueInner<RsCancellationToken>,
>>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
(move || async move {
let mut api_cancel_token_guard = None;
let decode_indices_ =
flutter_rust_bridge::for_generated::lockable_compute_decode_order(
vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new(
&api_cancel_token,
0,
false,
)],
);
for i in decode_indices_ {
match i {
0 => {
api_cancel_token_guard =
Some(api_cancel_token.lockable_decode_async_ref().await)
}
_ => unreachable!(),
}
}
let api_cancel_token_guard = api_cancel_token_guard.unwrap();
let output_ok = crate::api::crypto::hash_file(
api_path,
api_file_descriptor,
api_bytes,
&*api_cancel_token_guard,
)
.await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__server__start_server_impl( fn wire__crate__api__server__start_server_impl(
port_: flutter_rust_bridge::for_generated::MessagePort, port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -3452,6 +3520,17 @@ impl SseDecode for Option<Vec<String>> {
} }
} }
impl SseDecode for Option<Vec<u8>> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
if (<bool>::sse_decode(deserializer)) {
return Some(<Vec<u8>>::sse_decode(deserializer));
} else {
return None;
}
}
}
impl SseDecode for crate::api::webrtc::PinConfig { impl SseDecode for crate::api::webrtc::PinConfig {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -4194,8 +4273,9 @@ fn pde_ffi_dispatcher_primary_impl(
wire__crate__api__logging__enable_debug_logging_impl(port, ptr, rust_vec_len, data_len) wire__crate__api__logging__enable_debug_logging_impl(port, ptr, rust_vec_len, data_len)
} }
41 => wire__crate__api__crypto__generate_key_pair_impl(port, ptr, rust_vec_len, data_len), 41 => wire__crate__api__crypto__generate_key_pair_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__server__start_server_impl(port, ptr, rust_vec_len, data_len), 42 => wire__crate__api__crypto__hash_file_impl(port, ptr, rust_vec_len, data_len),
43 => wire__crate__api__crypto__verify_cert_impl(port, ptr, rust_vec_len, data_len), 43 => wire__crate__api__server__start_server_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__api__crypto__verify_cert_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(), _ => unreachable!(),
} }
} }
@@ -4209,8 +4289,8 @@ fn pde_ffi_dispatcher_sync_impl(
// Codec=Pde (Serialization + dispatch), see doc to use other codecs // Codec=Pde (Serialization + dispatch), see doc to use other codecs
match func_id { match func_id {
2 => wire__crate__api__stream__Dart2RustStreamSink_close_impl(ptr, rust_vec_len, data_len), 2 => wire__crate__api__stream__Dart2RustStreamSink_close_impl(ptr, rust_vec_len, data_len),
6 => wire__crate__api__http__RsCancellationToken_cancel_impl(ptr, rust_vec_len, data_len), 6 => wire__crate__api__cancel__RsCancellationToken_cancel_impl(ptr, rust_vec_len, data_len),
37 => wire__crate__api__http__create_cancellation_token_impl(ptr, rust_vec_len, data_len), 37 => wire__crate__api__cancel__create_cancellation_token_impl(ptr, rust_vec_len, data_len),
38 => wire__crate__api__http__create_client_impl(ptr, rust_vec_len, data_len), 38 => wire__crate__api__http__create_client_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(), _ => unreachable!(),
} }
@@ -5798,6 +5878,16 @@ impl SseEncode for Option<Vec<String>> {
} }
} }
impl SseEncode for Option<Vec<u8>> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<bool>::sse_encode(self.is_some(), serializer);
if let Some(value) = self {
<Vec<u8>>::sse_encode(value, serializer);
}
}
}
impl SseEncode for crate::api::webrtc::PinConfig { impl SseEncode for crate::api::webrtc::PinConfig {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -6263,6 +6353,7 @@ mod io {
// Section: imports // Section: imports
use super::*; use super::*;
use crate::api::cancel::*;
use crate::api::http::*; use crate::api::http::*;
use crate::api::server::*; use crate::api::server::*;
use crate::api::stream::*; use crate::api::stream::*;
@@ -6429,6 +6520,7 @@ mod web {
// Section: imports // Section: imports
use super::*; use super::*;
use crate::api::cancel::*;
use crate::api::http::*; use crate::api::http::*;
use crate::api::server::*; use crate::api::server::*;
use crate::api::stream::*; use crate::api::stream::*;