feat: add option to disable checksum verification
CI / format (push) Has been cancelled
CI / test (push) Has been cancelled
CI / packaging (push) Has been cancelled

This commit is contained in:
Tien Do Nam
2026-08-03 17:31:29 +02:00
parent c789295a47
commit 61b73b3641
25 changed files with 281 additions and 52 deletions
+4 -2
View File
@@ -120,11 +120,13 @@
"destination": "Save to folder",
"downloads": "(Downloads)",
"saveToGallery": "Save media to gallery",
"saveToHistory": "Save to history"
"saveToHistory": "Save to history",
"verifyChecksums": "Verify checksums when receiving files"
},
"send": {
"title": "Send",
"shareViaLinkAutoAccept": "Automatically accept requests in \"Share via link\" mode"
"shareViaLinkAutoAccept": "Automatically accept requests in \"Share via link\" mode",
"createChecksums": "Create checksums when sending files"
},
"network": {
"title": "Network",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 55
/// Strings: 18451 (335 per locale)
/// Strings: 18453 (335 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+6
View File
@@ -1088,6 +1088,9 @@ class Translations$settingsTab$receive$en {
/// en: 'Save to history'
String get saveToHistory => 'Save to history';
/// en: 'Verify checksums when receiving files'
String get verifyChecksums => 'Verify checksums when receiving files';
}
// Path: settingsTab.send
@@ -1103,6 +1106,9 @@ class Translations$settingsTab$send$en {
/// en: 'Automatically accept requests in "Share via link" mode'
String get shareViaLinkAutoAccept => 'Automatically accept requests in "Share via link" mode';
/// en: 'Create checksums when sending files'
String get createChecksums => 'Create checksums when sending files';
}
// Path: settingsTab.network
+4
View File
@@ -34,6 +34,8 @@ class SettingsState with SettingsStateMappable {
final String? deviceModel;
final bool shareViaLinkAutoAccept;
final bool receiveViaLinkAutoAccept;
final bool createChecksums; // create checksums when sending files
final bool verifyChecksums; // verify checksums when receiving files
final int discoveryTimeout;
final bool advancedSettings;
@@ -63,6 +65,8 @@ class SettingsState with SettingsStateMappable {
required this.deviceModel,
required this.shareViaLinkAutoAccept,
required this.receiveViaLinkAutoAccept,
required this.createChecksums,
required this.verifyChecksums,
required this.discoveryTimeout,
required this.advancedSettings,
});
@@ -144,6 +144,16 @@ class SettingsStateMapper extends ClassMapperBase<SettingsState> {
'receiveViaLinkAutoAccept',
_$receiveViaLinkAutoAccept,
);
static bool _$createChecksums(SettingsState v) => v.createChecksums;
static const Field<SettingsState, bool> _f$createChecksums = Field(
'createChecksums',
_$createChecksums,
);
static bool _$verifyChecksums(SettingsState v) => v.verifyChecksums;
static const Field<SettingsState, bool> _f$verifyChecksums = Field(
'verifyChecksums',
_$verifyChecksums,
);
static int _$discoveryTimeout(SettingsState v) => v.discoveryTimeout;
static const Field<SettingsState, int> _f$discoveryTimeout = Field(
'discoveryTimeout',
@@ -182,6 +192,8 @@ class SettingsStateMapper extends ClassMapperBase<SettingsState> {
#deviceModel: _f$deviceModel,
#shareViaLinkAutoAccept: _f$shareViaLinkAutoAccept,
#receiveViaLinkAutoAccept: _f$receiveViaLinkAutoAccept,
#createChecksums: _f$createChecksums,
#verifyChecksums: _f$verifyChecksums,
#discoveryTimeout: _f$discoveryTimeout,
#advancedSettings: _f$advancedSettings,
};
@@ -213,6 +225,8 @@ class SettingsStateMapper extends ClassMapperBase<SettingsState> {
deviceModel: data.dec(_f$deviceModel),
shareViaLinkAutoAccept: data.dec(_f$shareViaLinkAutoAccept),
receiveViaLinkAutoAccept: data.dec(_f$receiveViaLinkAutoAccept),
createChecksums: data.dec(_f$createChecksums),
verifyChecksums: data.dec(_f$verifyChecksums),
discoveryTimeout: data.dec(_f$discoveryTimeout),
advancedSettings: data.dec(_f$advancedSettings),
);
@@ -310,6 +324,8 @@ abstract class SettingsStateCopyWith<$R, $In extends SettingsState, $Out>
String? deviceModel,
bool? shareViaLinkAutoAccept,
bool? receiveViaLinkAutoAccept,
bool? createChecksums,
bool? verifyChecksums,
int? discoveryTimeout,
bool? advancedSettings,
});
@@ -369,6 +385,8 @@ class _SettingsStateCopyWithImpl<$R, $Out>
Object? deviceModel = $none,
bool? shareViaLinkAutoAccept,
bool? receiveViaLinkAutoAccept,
bool? createChecksums,
bool? verifyChecksums,
int? discoveryTimeout,
bool? advancedSettings,
}) => $apply(
@@ -402,6 +420,8 @@ class _SettingsStateCopyWithImpl<$R, $Out>
#shareViaLinkAutoAccept: shareViaLinkAutoAccept,
if (receiveViaLinkAutoAccept != null)
#receiveViaLinkAutoAccept: receiveViaLinkAutoAccept,
if (createChecksums != null) #createChecksums: createChecksums,
if (verifyChecksums != null) #verifyChecksums: verifyChecksums,
if (discoveryTimeout != null) #discoveryTimeout: discoveryTimeout,
if (advancedSettings != null) #advancedSettings: advancedSettings,
}),
@@ -445,6 +465,8 @@ class _SettingsStateCopyWithImpl<$R, $Out>
#receiveViaLinkAutoAccept,
or: $value.receiveViaLinkAutoAccept,
),
createChecksums: data.get(#createChecksums, or: $value.createChecksums),
verifyChecksums: data.get(#verifyChecksums, or: $value.verifyChecksums),
discoveryTimeout: data.get(#discoveryTimeout, or: $value.discoveryTimeout),
advancedSettings: data.get(#advancedSettings, or: $value.advancedSettings),
);
+20
View File
@@ -259,6 +259,19 @@ class SettingsTab extends StatelessWidget {
await ref.notifier(settingsProvider).setSaveToHistory(b);
},
),
if (vm.advanced)
_BooleanEntry(
label: t.settingsTab.receive.verifyChecksums,
value: vm.settings.verifyChecksums,
onChanged: (b) async {
await ref.notifier(settingsProvider).setVerifyChecksums(b);
// The checksums are verified by the Rust server, so it needs a restart.
if (ref.read(serverProvider) != null) {
await ref.notifier(serverProvider).restartServerFromSettings();
}
},
),
],
),
if (vm.advanced)
@@ -272,6 +285,13 @@ class SettingsTab extends StatelessWidget {
await ref.notifier(settingsProvider).setShareViaLinkAutoAccept(b);
},
),
_BooleanEntry(
label: t.settingsTab.send.createChecksums,
value: vm.settings.createChecksums,
onChanged: (b) async {
await ref.notifier(settingsProvider).setCreateChecksums(b);
},
),
],
),
_SettingsSection(
+7 -2
View File
@@ -74,6 +74,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
// if someone else answers on that address.
final client = ref.read(httpProvider).pinnedTo(target.fingerprint);
final sessionId = _uuid.v4();
final createChecksums = ref.read(settingsProvider).createChecksums;
// The ids are assigned upfront, so the checksums calculated below
// can be mapped back to the corresponding file.
@@ -116,7 +117,9 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
errorMessage: null,
),
},
hashedFileCount: 0,
// Skipping the checksums marks all files as hashed, so the UI does not
// show the checksum progress.
hashedFileCount: createChecksums ? 0 : selectedFiles.length,
startTime: null,
endTime: null,
sendingTasks: [],
@@ -134,9 +137,10 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
// Calculate the checksums which are part of the request.
// The files are read and hashed in Rust, one file after another.
final hashes = <String, String>{};
if (createChecksums) {
final hashCancelToken = rust_cancel.createCancellationToken();
_hashCancelTokens[sessionId] = hashCancelToken;
final hashes = <String, String>{};
try {
for (final (:id, :file) in selectedFiles) {
try {
@@ -183,6 +187,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
} finally {
_hashCancelTokens.remove(sessionId);
}
}
final hashedState = state[sessionId];
if (hashedState == null) {
@@ -126,6 +126,7 @@ class ServerService extends Notifier<ServerState?> {
.dispatchTakeResult(
IsolateHttpServerStartAction(
pin: settings.receivePin,
verifyChecksums: settings.verifyChecksums,
web: webSendState != null || webUpload
? WebParams(
send: webSendState != null
@@ -90,6 +90,8 @@ const _deviceType = 'ls_device_type';
const _deviceModel = 'ls_device_model';
const _shareViaLinkAutoAccept = 'ls_share_via_link_auto_accept';
const _receiveViaLinkAutoAccept = 'ls_receive_via_link_auto_accept';
const _createChecksums = 'ls_create_checksums';
const _verifyChecksums = 'ls_verify_checksums';
const _advancedSettingsKey = 'ls_advanced_settings';
const _whatsNewKey = 'ls_whats_new';
@@ -377,6 +379,22 @@ class PersistenceService {
await _prefs.setBool(_receiveViaLinkAutoAccept, receiveViaLinkAutoAccept);
}
bool getCreateChecksums() {
return _prefs.getBool(_createChecksums) ?? true;
}
Future<void> setCreateChecksums(bool createChecksums) async {
await _prefs.setBool(_createChecksums, createChecksums);
}
bool getVerifyChecksums() {
return _prefs.getBool(_verifyChecksums) ?? true;
}
Future<void> setVerifyChecksums(bool verifyChecksums) async {
await _prefs.setBool(_verifyChecksums, verifyChecksums);
}
String getMulticastGroup() {
return _prefs.getString(_multicastGroupKey) ?? defaultMulticastGroup;
}
+16
View File
@@ -70,6 +70,8 @@ class SettingsService extends PureNotifier<SettingsState> {
deviceModel: _persistence.getDeviceModel(),
shareViaLinkAutoAccept: _persistence.getShareViaLinkAutoAccept(),
receiveViaLinkAutoAccept: _persistence.getReceiveViaLinkAutoAccept(),
createChecksums: _persistence.getCreateChecksums(),
verifyChecksums: _persistence.getVerifyChecksums(),
discoveryTimeout: _persistence.getDiscoveryTimeout(),
advancedSettings: _persistence.getAdvancedSettingsEnabled(),
);
@@ -273,4 +275,18 @@ class SettingsService extends PureNotifier<SettingsState> {
receiveViaLinkAutoAccept: receiveViaLinkAutoAccept,
);
}
Future<void> setCreateChecksums(bool createChecksums) async {
await _persistence.setCreateChecksums(createChecksums);
state = state.copyWith(
createChecksums: createChecksums,
);
}
Future<void> setVerifyChecksums(bool verifyChecksums) async {
await _persistence.setVerifyChecksums(verifyChecksums);
state = state.copyWith(
verifyChecksums: verifyChecksums,
);
}
}
+36
View File
@@ -318,6 +318,42 @@ class MockPersistenceService extends _i1.Mock implements _i3.PersistenceService
)
as _i4.Future<void>);
@override
bool getCreateChecksums() =>
(super.noSuchMethod(
Invocation.method(#getCreateChecksums, []),
returnValue: false,
returnValueForMissingStub: false,
)
as bool);
@override
_i4.Future<void> setCreateChecksums(bool? createChecksums) =>
(super.noSuchMethod(
Invocation.method(#setCreateChecksums, [createChecksums]),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
)
as _i4.Future<void>);
@override
bool getVerifyChecksums() =>
(super.noSuchMethod(
Invocation.method(#getVerifyChecksums, []),
returnValue: false,
returnValueForMissingStub: false,
)
as bool);
@override
_i4.Future<void> setVerifyChecksums(bool? verifyChecksums) =>
(super.noSuchMethod(
Invocation.method(#setVerifyChecksums, [verifyChecksums]),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
)
as _i4.Future<void>);
@override
String getMulticastGroup() =>
(super.noSuchMethod(
+1
View File
@@ -95,6 +95,7 @@ pub async fn run(args: Args) -> anyhow::Result<()> {
None,
Some(ServerConfigV2 {
pin: None,
verify_checksums: true,
event_tx: server_tx,
}),
None,
+9
View File
@@ -39,6 +39,11 @@ pub struct ServerConfigV2 {
/// Optional PIN that senders must provide via the `pin` query parameter.
pub pin: Option<String>,
/// Whether the SHA-256 checksums that senders provide for their files are
/// verified after receiving. When disabled, received files are not hashed
/// and a mismatch is not detected.
pub verify_checksums: bool,
/// Channel on which the server emits events that must be handled by the application.
pub event_tx: mpsc::Sender<ServerEventV2>,
}
@@ -48,6 +53,9 @@ pub(crate) struct V2State {
/// Optional PIN required for prepare-upload requests.
pub(crate) pin: Option<String>,
/// Whether sender-provided SHA-256 checksums are verified after receiving.
pub(crate) verify_checksums: bool,
/// Channel on which server events are emitted to the application.
pub(crate) event_tx: mpsc::Sender<ServerEventV2>,
@@ -95,6 +103,7 @@ impl AppState {
let v2 = v2_config.map(|config| {
Arc::new(V2State {
pin: config.pin,
verify_checksums: config.verify_checksums,
event_tx: config.event_tx,
session: Mutex::new(None),
pin_attempts: Mutex::new(LruCache::new(NonZeroUsize::new(200).unwrap())),
+4 -1
View File
@@ -385,7 +385,10 @@ pub(crate) async fn upload(
let mut upload_guard = UploadGuard::new(v2.clone(), session_id.clone(), file_id.clone());
let file_size = file_dto.size;
let expected_sha256 = file_dto.sha256.clone();
let expected_sha256 = match v2.verify_checksums {
true => file_dto.sha256.clone(),
false => None,
};
let timestamps = match &file_dto.metadata {
Some(metadata) => FileTimestamps {
modified: metadata.modified_time(),
+1
View File
@@ -76,6 +76,7 @@ async fn start_register_server(
None,
Some(ServerConfigV2 {
pin: None,
verify_checksums: true,
event_tx,
}),
None,
+57 -1
View File
@@ -37,6 +37,16 @@ async fn start_test_server(
pin: Option<String>,
accept: bool,
save_dir: Option<PathBuf>,
) -> TestServer {
start_test_server_with_verification(pin, accept, save_dir, true).await
}
/// Like [start_test_server], but allows disabling the checksum verification.
async fn start_test_server_with_verification(
pin: Option<String>,
accept: bool,
save_dir: Option<PathBuf>,
verify_checksums: bool,
) -> TestServer {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let port = free_port();
@@ -125,7 +135,11 @@ async fn start_test_server(
token: "server-fingerprint".to_string(),
},
None,
Some(ServerConfigV2 { pin, event_tx }),
Some(ServerConfigV2 {
pin,
verify_checksums,
event_tx,
}),
None,
stop_rx,
)
@@ -499,6 +513,45 @@ async fn test_upload_with_mismatched_sha256() {
assert_status(result, 422);
}
#[tokio::test]
async fn test_upload_mismatched_sha256_with_verification_disabled() {
let server = start_test_server_with_verification(None, true, None, false).await;
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let bytes = b"hello".to_vec();
let mut file = file_dto("file-a", "a.bin", bytes.len() as u64);
file.sha256 = Some(sha256_hex(b"something else"));
let response = client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
server.port,
None,
prepare_upload_request(&[file]),
None,
CancellationToken::new(),
)
.await
.unwrap()
.response
.unwrap();
// The mismatch goes unnoticed because the received bytes are not hashed.
upload_bytes(
&client,
server.port,
&response.session_id,
"file-a",
&response.files["file-a"],
&bytes,
)
.await
.unwrap();
assert_eq!(server.received.lock().await["file-a"], bytes);
}
#[tokio::test]
async fn test_upload_retry_after_mismatched_sha256() {
let server = start_test_server(None, true, None).await;
@@ -927,6 +980,7 @@ async fn test_prepare_upload_aborted_by_sender_disconnect() {
None,
Some(ServerConfigV2 {
pin: None,
verify_checksums: true,
event_tx,
}),
None,
@@ -1029,6 +1083,7 @@ async fn test_prepare_upload_cancelled_by_session_less_cancel() {
None,
Some(ServerConfigV2 {
pin: None,
verify_checksums: true,
event_tx,
}),
None,
@@ -1164,6 +1219,7 @@ async fn test_prepare_upload_aborted_by_sender_disconnect_tls() {
None,
Some(ServerConfigV2 {
pin: None,
verify_checksums: true,
event_tx,
}),
None,
+1
View File
@@ -123,6 +123,7 @@ async fn start_tls_server_with_web(identity: &Identity, web: Option<WebConfig>)
None,
Some(ServerConfigV2 {
pin: None,
verify_checksums: true,
event_tx,
}),
web,
+2
View File
@@ -140,6 +140,7 @@ async fn start_test_server(
None,
Some(ServerConfigV2 {
pin: None,
verify_checksums: true,
event_tx: v2_event_tx,
}),
web_config,
@@ -340,6 +341,7 @@ async fn test_upload_page() {
None,
Some(ServerConfigV2 {
pin: None,
verify_checksums: true,
event_tx: v2_event_tx,
}),
Some(WebConfig {
@@ -34,6 +34,7 @@ Future<RsHttpServer> startServer({
DeviceType? deviceType,
required String fingerprint,
String? pin,
required bool verifyChecksums,
WebParams? web,
String? showToken,
}) => RustLib.instance.api.crateApiServerStartServer(
@@ -45,6 +46,7 @@ Future<RsHttpServer> startServer({
deviceType: deviceType,
fingerprint: fingerprint,
pin: pin,
verifyChecksums: verifyChecksums,
web: web,
showToken: showToken,
);
@@ -302,6 +302,7 @@ abstract class RustLibApi extends BaseApi {
DeviceType? deviceType,
required String fingerprint,
String? pin,
required bool verifyChecksums,
WebParams? web,
String? showToken,
});
@@ -2043,6 +2044,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
DeviceType? deviceType,
required String fingerprint,
String? pin,
required bool verifyChecksums,
WebParams? web,
String? showToken,
}) {
@@ -2058,6 +2060,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_opt_box_autoadd_device_type(deviceType, serializer);
sse_encode_String(fingerprint, serializer);
sse_encode_opt_String(pin, serializer);
sse_encode_bool(verifyChecksums, serializer);
sse_encode_opt_box_autoadd_web_params(web, serializer);
sse_encode_opt_String(showToken, serializer);
pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 55, port: port_);
@@ -2067,7 +2070,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
decodeErrorData: sse_decode_AnyhowException,
),
constMeta: kCrateApiServerStartServerConstMeta,
argValues: [port, tls, alias, version, deviceModel, deviceType, fingerprint, pin, web, showToken],
argValues: [port, tls, alias, version, deviceModel, deviceType, fingerprint, pin, verifyChecksums, web, showToken],
apiImpl: this,
),
);
@@ -2075,7 +2078,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiServerStartServerConstMeta => const TaskConstMeta(
debugName: 'start_server',
argNames: ['port', 'tls', 'alias', 'version', 'deviceModel', 'deviceType', 'fingerprint', 'pin', 'web', 'showToken'],
argNames: ['port', 'tls', 'alias', 'version', 'deviceModel', 'deviceType', 'fingerprint', 'pin', 'verifyChecksums', 'web', 'showToken'],
);
@override
@@ -30,6 +30,10 @@ class HttpServerStartTask implements BaseHttpServerTask {
/// Optional PIN that senders must provide to start an upload session.
final String? pin;
/// Whether the SHA-256 checksums that senders provide for their files are
/// verified after receiving.
final bool verifyChecksums;
/// Serves the web pages: the download page (web send) and/or the upload page.
/// `null` disables the web pages.
final WebParams? web;
@@ -40,6 +44,7 @@ class HttpServerStartTask implements BaseHttpServerTask {
HttpServerStartTask({
required this.pin,
required this.verifyChecksums,
required this.web,
required this.showToken,
});
@@ -398,6 +403,7 @@ Future<void> setupHttpServerIsolate(
deviceType: syncState.deviceInfo.deviceType.toRust(),
fingerprint: syncState.securityContext.certificateHash,
pin: startTask.pin,
verifyChecksums: startTask.verifyChecksums,
web: startTask.web,
showToken: startTask.showToken,
);
@@ -256,6 +256,10 @@ class IsolateHttpUploadCancelAction extends ReduxAction<IsolateController, Paren
class IsolateHttpServerStartAction extends ReduxActionWithResult<IsolateController, ParentIsolateState, Stream<HttpServerEvent>> {
final String? pin;
/// Whether the SHA-256 checksums that senders provide for their files are
/// verified after receiving.
final bool verifyChecksums;
/// Serves the web pages: the download page (web send) and/or the upload page.
/// `null` disables the web pages.
final WebParams? web;
@@ -266,6 +270,7 @@ class IsolateHttpServerStartAction extends ReduxActionWithResult<IsolateControll
IsolateHttpServerStartAction({
required this.pin,
required this.verifyChecksums,
required this.web,
required this.showToken,
});
@@ -282,6 +287,7 @@ class IsolateHttpServerStartAction extends ReduxActionWithResult<IsolateControll
connection.sendWrappedTaskAndListenStream(
task: HttpServerStartTask(
pin: pin,
verifyChecksums: verifyChecksums,
web: web,
showToken: showToken,
),
@@ -22,6 +22,7 @@ class HttpServerService {
required DeviceType? deviceType,
required String fingerprint,
required String? pin,
required bool verifyChecksums,
required WebParams? web,
required String? showToken,
}) async {
@@ -38,6 +39,7 @@ class HttpServerService {
deviceType: deviceType,
fingerprint: fingerprint,
pin: pin,
verifyChecksums: verifyChecksums,
web: web,
showToken: showToken,
);
@@ -183,6 +183,7 @@ pub async fn start_server(
device_type: Option<DeviceType>,
fingerprint: String,
pin: Option<String>,
verify_checksums: bool,
web: Option<WebParams>,
show_token: Option<String>,
) -> anyhow::Result<RsHttpServer> {
@@ -243,7 +244,11 @@ pub async fn start_server(
token: fingerprint,
},
internal_config,
Some(ServerConfigV2 { pin, event_tx }),
Some(ServerConfigV2 {
pin,
verify_checksums,
event_tx,
}),
web_config,
stop_rx,
)
@@ -3201,6 +3201,7 @@ fn wire__crate__api__server__start_server_impl(
<Option<crate::api::model::DeviceType>>::sse_decode(&mut deserializer);
let api_fingerprint = <String>::sse_decode(&mut deserializer);
let api_pin = <Option<String>>::sse_decode(&mut deserializer);
let api_verify_checksums = <bool>::sse_decode(&mut deserializer);
let api_web = <Option<crate::api::server::WebParams>>::sse_decode(&mut deserializer);
let api_show_token = <Option<String>>::sse_decode(&mut deserializer);
deserializer.end();
@@ -3216,6 +3217,7 @@ fn wire__crate__api__server__start_server_impl(
api_device_type,
api_fingerprint,
api_pin,
api_verify_checksums,
api_web,
api_show_token,
)