From da4464def254223af4614687947c6eaf1d7a646a Mon Sep 17 00:00:00 2001 From: Tien Do Nam Date: Sat, 1 Aug 2026 17:17:41 +0200 Subject: [PATCH] feat: path sanitizing in core --- .../dialogs/file_name_input_dialog.dart | 6 +- .../widget/dialogs/quick_actions_dialog.dart | 6 +- app/pubspec.lock | 8 - app/pubspec.yaml | 3 - cli/src/util.rs | 11 +- packages/core/src/discovery/mod.rs | 2 +- packages/core/src/discovery/store.rs | 12 +- packages/core/src/lib.rs | 2 +- packages/core/src/util/filename.rs | 388 ++++++++++++++++++ packages/core/src/util/mod.rs | 1 + packages/localsend_isolates/.gitignore | 1 + .../lib/rust/api/filename.dart | 15 + .../lib/rust/frb_generated.dart | 63 ++- .../lib/rust/frb_generated.io.dart | 1 + .../lib/rust/frb_generated.web.dart | 1 + .../lib/src/task/server/file_saver.dart | 4 +- packages/localsend_isolates/pubspec.lock | 8 - packages/localsend_isolates/pubspec.yaml | 1 - .../rust/src/api/filename.rs | 16 + .../localsend_isolates/rust/src/api/mod.rs | 1 + .../rust/src/frb_generated.rs | 72 +++- .../test/task/server/file_saver_test.dart | 14 + 22 files changed, 588 insertions(+), 48 deletions(-) create mode 100644 packages/core/src/util/filename.rs create mode 100644 packages/localsend_isolates/lib/rust/api/filename.dart create mode 100644 packages/localsend_isolates/rust/src/api/filename.rs diff --git a/app/lib/widget/dialogs/file_name_input_dialog.dart b/app/lib/widget/dialogs/file_name_input_dialog.dart index d373edc3..1dcbc835 100644 --- a/app/lib/widget/dialogs/file_name_input_dialog.dart +++ b/app/lib/widget/dialogs/file_name_input_dialog.dart @@ -1,9 +1,7 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; -import 'package:legalize/legalize.dart'; import 'package:localsend_app/config/theme.dart'; import 'package:localsend_app/gen/strings.g.dart'; +import 'package:localsend_isolates/rust/api/filename.dart'; import 'package:localsend_isolates/util/file_path_helper.dart'; import 'package:routerino/routerino.dart'; @@ -38,7 +36,7 @@ class _FileNameInputDialogState extends State { return false; } - if (!isValidFilename(input, os: Platform.operatingSystem)) { + if (!isValidFileName(name: input)) { setState(() { _errorMessage = t.sanitization.invalid; }); diff --git a/app/lib/widget/dialogs/quick_actions_dialog.dart b/app/lib/widget/dialogs/quick_actions_dialog.dart index b2622998..6e01a67e 100644 --- a/app/lib/widget/dialogs/quick_actions_dialog.dart +++ b/app/lib/widget/dialogs/quick_actions_dialog.dart @@ -1,11 +1,9 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; -import 'package:legalize/legalize.dart'; import 'package:localsend_app/config/theme.dart'; import 'package:localsend_app/gen/strings.g.dart'; import 'package:localsend_app/provider/selection/selected_receiving_files_provider.dart'; import 'package:localsend_app/widget/labeled_checkbox.dart'; +import 'package:localsend_isolates/rust/api/filename.dart'; import 'package:refena_flutter/refena_flutter.dart'; import 'package:routerino/routerino.dart'; import 'package:uuid/uuid.dart'; @@ -47,7 +45,7 @@ class _QuickActionsDialogState extends State with Refena { bool _isValid = true; bool _validate(String input) { - if (!isValidFilename(input, os: Platform.operatingSystem) && input.isNotEmpty) { + if (!isValidFileName(name: input) && input.isNotEmpty) { setState(() { _isValid = false; }); diff --git a/app/pubspec.lock b/app/pubspec.lock index bdf83943..cb55dedf 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -846,14 +846,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" - legalize: - dependency: "direct main" - description: - name: legalize - sha256: bc3068aa4f14588575c8b5ba2a9e608c242dad325e7f7c56fedd68adba33526a - url: "https://pub.dev" - source: hosted - version: "1.2.2" lints: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index bfc7cfad..fc876deb 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -31,13 +31,10 @@ dependencies: image_picker: 1.2.3 in_app_purchase: 3.3.0 # [FOSS_REMOVE] intl: ^0.20.2 # allow newer versions, so it can compile with newer Flutter versions - legalize: 1.2.2 local_hero: 0.3.0 localsend_isolates: path: ../packages/localsend_isolates logging: 1.3.0 - # https://github.com/NightFeather0615/macos_dock_progress/issues/1 - # macos_dock_progress: 1.1.0 mime: 2.0.0 moform: 0.2.11 nanoid2: 2.0.1 diff --git a/cli/src/util.rs b/cli/src/util.rs index 089cbeb5..2e3503bf 100644 --- a/cli/src/util.rs +++ b/cli/src/util.rs @@ -1,5 +1,6 @@ use crossterm::terminal::{Clear, ClearType}; use crossterm::{cursor, execute}; +use localsend::util::filename; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -88,13 +89,11 @@ pub fn progress_bar(fraction: f64, width: usize) -> String { /// A path in `dir` for `file_name` that does not exist yet, appending /// ` (1)`, ` (2)`, … before the extension on collisions. /// -/// Only the final path component of `file_name` is used, so a malicious -/// sender cannot escape the target directory. +/// `file_name` comes from the sender and is untrusted: it is collapsed to its +/// final path component and sanitized for the local filesystem, so it can +/// neither escape the target directory nor carry illegal characters. pub fn unique_path(dir: &Path, file_name: &str) -> PathBuf { - let name = Path::new(file_name) - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| "unnamed".to_string()); + let name = filename::sanitize_path(file_name, filename::Rules::current()); let candidate = dir.join(&name); if !candidate.exists() { diff --git a/packages/core/src/discovery/mod.rs b/packages/core/src/discovery/mod.rs index 0a76e609..93c1b36f 100644 --- a/packages/core/src/discovery/mod.rs +++ b/packages/core/src/discovery/mod.rs @@ -14,8 +14,8 @@ use crate::multicast::{ use futures_util::StreamExt; use std::collections::HashSet; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::time::{Duration, SystemTime}; use store::DeviceStore; use tokio::sync::{mpsc, oneshot}; diff --git a/packages/core/src/discovery/store.rs b/packages/core/src/discovery/store.rs index 818222a8..a4f0e59e 100644 --- a/packages/core/src/discovery/store.rs +++ b/packages/core/src/discovery/store.rs @@ -454,7 +454,11 @@ mod tests { store.upsert(device("a", "fe80::1%3"), SystemTime::now()); let mut known = store.by_fingerprint("a").unwrap(); - let hosts: Vec<&str> = known.get_ranked_channels().into_iter().map(channel_host).collect(); + let hosts: Vec<&str> = known + .get_ranked_channels() + .into_iter() + .map(channel_host) + .collect(); assert_eq!( hosts, ["fe80::1%3", "10.0.0.10", "192.168.0.10"], @@ -466,7 +470,11 @@ mod tests { *status = ChannelStatus::NotReachable; } } - let hosts: Vec<&str> = known.get_ranked_channels().into_iter().map(channel_host).collect(); + let hosts: Vec<&str> = known + .get_ranked_channels() + .into_iter() + .map(channel_host) + .collect(); assert_eq!( hosts, ["10.0.0.10", "192.168.0.10", "fe80::1%3"], diff --git a/packages/core/src/lib.rs b/packages/core/src/lib.rs index ae72b099..a80bdbaf 100644 --- a/packages/core/src/lib.rs +++ b/packages/core/src/lib.rs @@ -7,7 +7,7 @@ pub mod http; pub mod model; #[cfg(feature = "multicast")] pub mod multicast; -pub(crate) mod util; +pub mod util; pub mod webrtc; #[cfg(feature = "http")] diff --git a/packages/core/src/util/filename.rs b/packages/core/src/util/filename.rs new file mode 100644 index 00000000..1fb5dbd6 --- /dev/null +++ b/packages/core/src/util/filename.rs @@ -0,0 +1,388 @@ +//! Filename sanitization for untrusted, peer-supplied file names. + +/// Characters that are illegal on Windows and FAT volumes. +const ILLEGAL_WINDOWS_CHARS: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + +/// Characters that are illegal on HFS/APFS. `:` is the classic Mac path +/// separator and still shows up as `/` in Finder. +const ILLEGAL_HFS_CHARS: &[char] = &['/', ':']; + +/// Characters that are illegal on POSIX filesystems. +const ILLEGAL_POSIX_CHARS: &[char] = &['/']; + +/// Device names Windows reserves regardless of extension. +const RESERVED_WINDOWS_NAMES: &[&str] = &[ + "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", + "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", +]; + +/// Maximum file name length in bytes, the limit on ext4, APFS and HFS+. +const MAX_LEN: usize = 255; + +/// The naming rules to apply, selected by target filesystem rather than by OS +/// so that callers can sanitize for a destination that is not the local one +/// (a SAF tree on external FAT storage, for instance). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Rules { + /// NTFS: illegal characters, reserved device names, no trailing `.` or ` `. + Windows, + /// HFS+/APFS: `/` and `:`. + Hfs, + /// FAT/exFAT, the common case for Android external storage: the Windows + /// character set without the reserved names. + Fat, + /// POSIX: `/` and NUL only. + Posix, + /// The intersection of all of the above. Use when the destination + /// filesystem is unknown or the file may be copied between platforms. + Universal, +} + +impl Rules { + /// The rules for the platform this binary was compiled for. + pub const fn current() -> Self { + if cfg!(target_os = "windows") { + Self::Windows + } else if cfg!(any(target_os = "macos", target_os = "ios")) { + Self::Hfs + } else if cfg!(target_os = "android") { + Self::Fat + } else if cfg!(unix) { + Self::Posix + } else { + Self::Universal + } + } + + fn illegal_chars(self) -> &'static [char] { + match self { + Self::Windows | Self::Fat => ILLEGAL_WINDOWS_CHARS, + Self::Hfs => ILLEGAL_HFS_CHARS, + Self::Posix => ILLEGAL_POSIX_CHARS, + // `Universal` is handled by combining the sets, see `is_illegal_char`. + Self::Universal => &[], + } + } + + fn is_illegal_char(self, c: char) -> bool { + if c.is_control() { + return true; + } + match self { + Self::Universal => ILLEGAL_WINDOWS_CHARS.contains(&c) || ILLEGAL_HFS_CHARS.contains(&c), + _ => self.illegal_chars().contains(&c), + } + } + + /// Whether reserved device names and trailing `.`/` ` matter. + fn is_windows_like(self) -> bool { + matches!(self, Self::Windows | Self::Universal) + } +} + +/// Options for [`sanitize_with`]. +#[derive(Debug, Clone, Copy)] +pub struct Options<'a> { + /// Substituted for each illegal character. + pub replacement: &'a str, + /// Used when sanitization leaves the name empty. + pub placeholder: &'a str, +} + +impl Default for Options<'_> { + fn default() -> Self { + Self { + replacement: "_", + placeholder: "untitled", + } + } +} + +/// Rewrites `name` into a file name that is legal under `rules`, using the +/// default [`Options`]. +/// +/// `name` must already be a single path segment; this does not split paths and +/// will replace any separator it finds. Callers holding a peer-supplied path +/// should take the last segment first — see [`sanitize_path`]. +pub fn sanitize(name: &str, rules: Rules) -> String { + sanitize_with(name, rules, &Options::default()) +} + +/// [`sanitize`] with explicit replacement and placeholder strings. +pub fn sanitize_with(name: &str, rules: Rules, options: &Options) -> String { + let mut result = String::with_capacity(name.len()); + for c in name.chars() { + if rules.is_illegal_char(c) { + result.push_str(options.replacement); + } else { + result.push(c); + } + } + + if rules.is_windows_like() { + collapse_trailing_run(&mut result, options.replacement); + + if let Some(reserved) = reserved_prefix(&result) { + result = format!("{}{}", options.replacement, &result[reserved.len()..]); + } + } + + truncate_bytes(&mut result, MAX_LEN); + + if rules.is_windows_like() { + // Truncation can cut right after a `.` or ` `, leaving a trailing run + // that did not exist before the cut. + collapse_trailing_run(&mut result, options.replacement); + truncate_bytes(&mut result, MAX_LEN); + } + + if result.is_empty() || is_relative(&result) { + result = options.placeholder.to_string(); + truncate_bytes(&mut result, MAX_LEN); + } + + result +} + +/// Replaces a trailing run of `.`/` ` with a single `replacement`. +fn collapse_trailing_run(result: &mut String, replacement: &str) { + let trimmed_len = result.trim_end_matches(['.', ' ']).len(); + if trimmed_len != result.len() { + result.truncate(trimmed_len); + result.push_str(replacement); + } +} + +/// Whether `name` is already legal under `rules`, i.e. whether [`sanitize`] +/// would leave it untouched. Suitable for validating user input before it is +/// written. +pub fn is_valid(name: &str, rules: Rules) -> bool { + if name.is_empty() || name.len() > MAX_LEN || is_relative(name) { + return false; + } + + if name.chars().any(|c| rules.is_illegal_char(c)) { + return false; + } + + if rules.is_windows_like() + && (name.ends_with('.') || name.ends_with(' ') || reserved_prefix(name).is_some()) + { + return false; + } + + true +} + +/// Sanitizes a peer-supplied *path* — a name that may carry directory +/// components, as protocol v2 allows for folder transfers — into a single +/// legal file name. +/// +/// Only the last segment survives, so `../../etc/passwd` becomes `passwd`: +/// this collapses the path rather than preserving the directory structure. +pub fn sanitize_path(path: &str, rules: Rules) -> String { + let last = path + .rsplit(['/', '\\']) + .find(|segment| !segment.is_empty() && !is_relative(segment)) + .unwrap_or(""); + + sanitize(last, rules) +} + +/// Returns the reserved device name `name` starts with, if its stem (the part +/// before the first `.`) is reserved. +fn reserved_prefix(name: &str) -> Option<&'static str> { + let stem = name.split('.').next().unwrap_or(name); + RESERVED_WINDOWS_NAMES + .iter() + .copied() + .find(|reserved| stem.eq_ignore_ascii_case(reserved)) +} + +fn is_relative(name: &str) -> bool { + name == "." || name == ".." +} + +/// Truncates in place to at most `max` bytes, cutting on a character boundary. +fn truncate_bytes(value: &mut String, max: usize) { + if value.len() <= max { + return; + } + + let mut end = max; + while !value.is_char_boundary(end) { + end -= 1; + } + value.truncate(end); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_replaces_illegal_characters() { + assert_eq!( + sanitize("ac:d\"e/f\\g|h?i*j", Rules::Windows), + "a_b_c_d_e_f_g_h_i_j" + ); + assert_eq!(sanitize("a/b:c", Rules::Hfs), "a_b_c"); + assert_eq!(sanitize("a/b:c", Rules::Posix), "a_b:c"); + assert_eq!(sanitize("a\u{0}b\u{7f}c", Rules::Posix), "a_b_c"); + } + + #[test] + fn test_keeps_legal_names() { + for rules in [ + Rules::Windows, + Rules::Hfs, + Rules::Fat, + Rules::Posix, + Rules::Universal, + ] { + assert_eq!( + sanitize("holiday photo (1).jpg", rules), + "holiday photo (1).jpg" + ); + assert_eq!(sanitize("Ünïcödé — 文件.txt", rules), "Ünïcödé — 文件.txt"); + } + } + + #[test] + fn test_windows_trailing_characters() { + assert_eq!(sanitize("report.", Rules::Windows), "report_"); + assert_eq!(sanitize("report... ", Rules::Windows), "report_"); + assert_eq!(sanitize("report.", Rules::Posix), "report."); + assert_eq!(sanitize("report.", Rules::Fat), "report."); + } + + #[test] + fn test_reserved_windows_names() { + assert_eq!(sanitize("con", Rules::Windows), "_"); + assert_eq!(sanitize("NUL.txt", Rules::Windows), "_.txt"); + assert_eq!(sanitize("com9.tar.gz", Rules::Windows), "_.tar.gz"); + // Only the exact stem is reserved. + assert_eq!(sanitize("console.txt", Rules::Windows), "console.txt"); + assert_eq!(sanitize("com10.txt", Rules::Windows), "com10.txt"); + // FAT has no reserved names. + assert_eq!(sanitize("con", Rules::Fat), "con"); + } + + #[test] + fn test_placeholder() { + assert_eq!(sanitize("", Rules::Posix), "untitled"); + assert_eq!(sanitize(".", Rules::Posix), "untitled"); + assert_eq!(sanitize("..", Rules::Posix), "untitled"); + assert_eq!(sanitize("///", Rules::Posix), "___"); + + let options = Options { + replacement: "", + placeholder: "unnamed", + }; + assert_eq!(sanitize_with("///", Rules::Posix, &options), "unnamed"); + assert_eq!(sanitize_with("a/b", Rules::Posix, &options), "ab"); + } + + #[test] + fn test_truncates_on_char_boundary() { + let long = "ä".repeat(200); // 400 bytes + let sanitized = sanitize(&long, Rules::Posix); + assert_eq!(sanitized.len(), MAX_LEN - 1); // 254: 127 × 2 bytes + assert!(sanitized.chars().all(|c| c == 'ä')); + } + + /// Truncation must not leave a trailing `.` or ` ` behind on Windows-like + /// rules — the cut can land right after one. + #[test] + fn test_truncation_does_not_expose_trailing_run() { + let dot = format!("{}.{}", "a".repeat(254), "b".repeat(10)); + let sanitized = sanitize(&dot, Rules::Windows); + assert_eq!(sanitized, format!("{}_", "a".repeat(254))); + assert!(is_valid(&sanitized, Rules::Windows)); + + let space = format!("{} {}", "a".repeat(254), "b".repeat(10)); + assert_eq!( + sanitize(&space, Rules::Windows), + format!("{}_", "a".repeat(254)) + ); + + // POSIX allows trailing dots, so the cut stays as-is there. + assert_eq!( + sanitize(&dot, Rules::Posix), + format!("{}.", "a".repeat(254)) + ); + } + + #[test] + fn test_sanitize_path_collapses_directories() { + assert_eq!(sanitize_path("../../etc/passwd", Rules::Posix), "passwd"); + assert_eq!(sanitize_path("a/b/c.txt", Rules::Posix), "c.txt"); + assert_eq!( + sanitize_path("C:\\Windows\\evil.exe", Rules::Windows), + "evil.exe" + ); + assert_eq!(sanitize_path("dir/", Rules::Posix), "dir"); + assert_eq!(sanitize_path("..", Rules::Posix), "untitled"); + assert_eq!(sanitize_path("/", Rules::Posix), "untitled"); + } + + #[test] + fn test_is_valid() { + assert!(is_valid("photo.jpg", Rules::Windows)); + assert!(!is_valid("", Rules::Windows)); + assert!(!is_valid("a:b", Rules::Windows)); + assert!(!is_valid("a:b", Rules::Hfs)); + assert!(is_valid("a:b", Rules::Posix)); + assert!(!is_valid("con.txt", Rules::Windows)); + assert!(is_valid("con.txt", Rules::Posix)); + assert!(!is_valid("trailing.", Rules::Windows)); + assert!(!is_valid("..", Rules::Posix)); + assert!(!is_valid(&"a".repeat(256), Rules::Posix)); + assert!(is_valid(&"a".repeat(255), Rules::Posix)); + } + + /// `is_valid` must agree with `sanitize` — otherwise a name the UI accepts + /// still gets rewritten on save, or vice versa. + #[test] + fn test_is_valid_matches_sanitize() { + let names = [ + "photo.jpg", + "", + ".", + "..", + "a:b", + "a/b", + "a\\b", + "a\u{0}b", + "a\u{1f}b", + "con", + "con.txt", + "console", + "lpt9.tar.gz", + "trailing.", + "trailing ", + "trailing...", + " leading", + "Ünïcödé.txt", + "文件.txt", + &"a".repeat(255), + &"a".repeat(256), + ]; + + for rules in [ + Rules::Windows, + Rules::Hfs, + Rules::Fat, + Rules::Posix, + Rules::Universal, + ] { + for name in names { + assert_eq!( + is_valid(name, rules), + sanitize(name, rules) == name, + "mismatch for {name:?} under {rules:?}" + ); + } + } + } +} diff --git a/packages/core/src/util/mod.rs b/packages/core/src/util/mod.rs index dad3ff51..4a4f59e4 100644 --- a/packages/core/src/util/mod.rs +++ b/packages/core/src/util/mod.rs @@ -1,2 +1,3 @@ pub mod base64; +pub mod filename; pub(crate) mod time; diff --git a/packages/localsend_isolates/.gitignore b/packages/localsend_isolates/.gitignore index 935d9aed..68fcca3d 100644 --- a/packages/localsend_isolates/.gitignore +++ b/packages/localsend_isolates/.gitignore @@ -2,3 +2,4 @@ .dart_tool/ .flutter-plugins .flutter-plugins-dependencies +/build/ diff --git a/packages/localsend_isolates/lib/rust/api/filename.dart b/packages/localsend_isolates/lib/rust/api/filename.dart new file mode 100644 index 00000000..7ff86f81 --- /dev/null +++ b/packages/localsend_isolates/lib/rust/api/filename.dart @@ -0,0 +1,15 @@ +// 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'; + +/// Rewrites `name` into a file name that is legal on the current platform, +/// replacing illegal characters with `_`. +String sanitizeFileName({required String name}) => RustLib.instance.api.crateApiFilenameSanitizeFileName(name: name); + +/// Whether `name` is a legal file name on the current platform, i.e. whether +/// [sanitize_file_name] would leave it untouched. +bool isValidFileName({required String name}) => RustLib.instance.api.crateApiFilenameIsValidFileName(name: name); diff --git a/packages/localsend_isolates/lib/rust/frb_generated.dart b/packages/localsend_isolates/lib/rust/frb_generated.dart index cce9389b..86580f14 100644 --- a/packages/localsend_isolates/lib/rust/frb_generated.dart +++ b/packages/localsend_isolates/lib/rust/frb_generated.dart @@ -10,6 +10,7 @@ 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/discovery.dart'; +import 'package:localsend_isolates/rust/api/filename.dart'; import 'package:localsend_isolates/rust/api/http.dart'; import 'package:localsend_isolates/rust/api/logging.dart'; import 'package:localsend_isolates/rust/api/model.dart'; @@ -74,7 +75,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -895476497; + int get rustContentHash => -1120530143; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( stem: 'rust_lib_localsend_app', @@ -271,6 +272,10 @@ abstract class RustLibApi extends BaseApi { Stream crateApiCryptoHashFile({String? path, int? fileDescriptor, Uint8List? bytes, required RsCancellationToken cancelToken}); + bool crateApiFilenameIsValidFileName({required String name}); + + String crateApiFilenameSanitizeFileName({required String name}); + Future crateApiDiscoveryStartDiscovery({ required String group, required int port, @@ -1895,6 +1900,56 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ['sink', 'path', 'fileDescriptor', 'bytes', 'cancelToken'], ); + @override + bool crateApiFilenameIsValidFileName({required String name}) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(name, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 52)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_bool, + decodeErrorData: null, + ), + constMeta: kCrateApiFilenameIsValidFileNameConstMeta, + argValues: [name], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFilenameIsValidFileNameConstMeta => const TaskConstMeta( + debugName: 'is_valid_file_name', + argNames: ['name'], + ); + + @override + String crateApiFilenameSanitizeFileName({required String name}) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(name, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 53)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: null, + ), + constMeta: kCrateApiFilenameSanitizeFileNameConstMeta, + argValues: [name], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFilenameSanitizeFileNameConstMeta => const TaskConstMeta( + debugName: 'sanitize_file_name', + argNames: ['name'], + ); + @override Future crateApiDiscoveryStartDiscovery({ required String group, @@ -1930,7 +1985,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(certPem, serializer); sse_encode_String(privateKeyPem, serializer); sse_encode_u_64(timeoutMs, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 52, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 54, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsDiscovery, @@ -2005,7 +2060,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_opt_String(pin, serializer); sse_encode_opt_box_autoadd_web_send_params(webSend, serializer); sse_encode_opt_String(showToken, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 53, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 55, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer, @@ -2031,7 +2086,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(cert, serializer); sse_encode_String(publicKey, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 54, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 56, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, diff --git a/packages/localsend_isolates/lib/rust/frb_generated.io.dart b/packages/localsend_isolates/lib/rust/frb_generated.io.dart index caa5e75c..8889d432 100644 --- a/packages/localsend_isolates/lib/rust/frb_generated.io.dart +++ b/packages/localsend_isolates/lib/rust/frb_generated.io.dart @@ -11,6 +11,7 @@ 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/discovery.dart'; +import 'package:localsend_isolates/rust/api/filename.dart'; import 'package:localsend_isolates/rust/api/http.dart'; import 'package:localsend_isolates/rust/api/logging.dart'; import 'package:localsend_isolates/rust/api/model.dart'; diff --git a/packages/localsend_isolates/lib/rust/frb_generated.web.dart b/packages/localsend_isolates/lib/rust/frb_generated.web.dart index 07f8e178..b7ca336f 100644 --- a/packages/localsend_isolates/lib/rust/frb_generated.web.dart +++ b/packages/localsend_isolates/lib/rust/frb_generated.web.dart @@ -13,6 +13,7 @@ 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/discovery.dart'; +import 'package:localsend_isolates/rust/api/filename.dart'; import 'package:localsend_isolates/rust/api/http.dart'; import 'package:localsend_isolates/rust/api/logging.dart'; import 'package:localsend_isolates/rust/api/model.dart'; diff --git a/packages/localsend_isolates/lib/src/task/server/file_saver.dart b/packages/localsend_isolates/lib/src/task/server/file_saver.dart index fe86ad6e..9d35589b 100644 --- a/packages/localsend_isolates/lib/src/task/server/file_saver.dart +++ b/packages/localsend_isolates/lib/src/task/server/file_saver.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:gal/gal.dart'; -import 'package:legalize/legalize.dart'; +import 'package:localsend_isolates/rust/api/filename.dart' as rust_filename; import 'package:localsend_isolates/util/android_channel.dart' as android_channel; import 'package:localsend_isolates/util/content_uri_helper.dart'; import 'package:localsend_isolates/util/file_path_helper.dart'; @@ -183,7 +183,7 @@ Future<(String, String?, String)> digestFilePathAndPrepareDirectory({ return (destinationUri, documentUri, p.basename(fileName)); } - final actualFileName = legalizeFilename(p.basename(fileName), os: Platform.operatingSystem); + final actualFileName = rust_filename.sanitizeFileName(name: p.basename(fileName)); final fileNameParts = p.split(fileName); final dir = p.joinAll([parentDirectory, ...fileNameParts.take(fileNameParts.length - 1)]); diff --git a/packages/localsend_isolates/pubspec.lock b/packages/localsend_isolates/pubspec.lock index 882c639b..551b4435 100644 --- a/packages/localsend_isolates/pubspec.lock +++ b/packages/localsend_isolates/pubspec.lock @@ -344,14 +344,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" - legalize: - dependency: "direct main" - description: - name: legalize - sha256: bc3068aa4f14588575c8b5ba2a9e608c242dad325e7f7c56fedd68adba33526a - url: "https://pub.dev" - source: hosted - version: "1.2.2" lints: dependency: transitive description: diff --git a/packages/localsend_isolates/pubspec.yaml b/packages/localsend_isolates/pubspec.yaml index 7777d840..9c07a35d 100644 --- a/packages/localsend_isolates/pubspec.yaml +++ b/packages/localsend_isolates/pubspec.yaml @@ -17,7 +17,6 @@ dependencies: flutter_rust_bridge: 2.12.0 freezed_annotation: 3.1.0 gal: 2.3.2 - legalize: 1.2.2 logging: 1.3.0 mime: 2.0.0 path: 1.9.1 diff --git a/packages/localsend_isolates/rust/src/api/filename.rs b/packages/localsend_isolates/rust/src/api/filename.rs new file mode 100644 index 00000000..072ce8e1 --- /dev/null +++ b/packages/localsend_isolates/rust/src/api/filename.rs @@ -0,0 +1,16 @@ +use flutter_rust_bridge::frb; +use localsend::util::filename; + +/// Rewrites `name` into a file name that is legal on the current platform, +/// replacing illegal characters with `_`. +#[frb(sync)] +pub fn sanitize_file_name(name: String) -> String { + filename::sanitize(&name, filename::Rules::current()) +} + +/// Whether `name` is a legal file name on the current platform, i.e. whether +/// [sanitize_file_name] would leave it untouched. +#[frb(sync)] +pub fn is_valid_file_name(name: String) -> bool { + filename::is_valid(&name, filename::Rules::current()) +} diff --git a/packages/localsend_isolates/rust/src/api/mod.rs b/packages/localsend_isolates/rust/src/api/mod.rs index 97013188..d6f3549b 100644 --- a/packages/localsend_isolates/rust/src/api/mod.rs +++ b/packages/localsend_isolates/rust/src/api/mod.rs @@ -1,6 +1,7 @@ pub mod cancel; pub mod crypto; pub mod discovery; +pub mod filename; pub mod http; pub mod logging; pub mod model; diff --git a/packages/localsend_isolates/rust/src/frb_generated.rs b/packages/localsend_isolates/rust/src/frb_generated.rs index aaa7bcba..68693fa2 100644 --- a/packages/localsend_isolates/rust/src/frb_generated.rs +++ b/packages/localsend_isolates/rust/src/frb_generated.rs @@ -44,7 +44,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -895476497; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1120530143; // Section: executor @@ -3042,6 +3042,68 @@ fn wire__crate__api__crypto__hash_file_impl( }, ) } +fn wire__crate__api__filename__is_valid_file_name_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "is_valid_file_name", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + 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_name = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = + Result::<_, ()>::Ok(crate::api::filename::is_valid_file_name(api_name))?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__filename__sanitize_file_name_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "sanitize_file_name", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + 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_name = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = + Result::<_, ()>::Ok(crate::api::filename::sanitize_file_name(api_name))?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__api__discovery__start_discovery_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -5044,9 +5106,9 @@ fn pde_ffi_dispatcher_primary_impl( data_len, ), 51 => wire__crate__api__crypto__hash_file_impl(port, ptr, rust_vec_len, data_len), - 52 => wire__crate__api__discovery__start_discovery_impl(port, ptr, rust_vec_len, data_len), - 53 => wire__crate__api__server__start_server_impl(port, ptr, rust_vec_len, data_len), - 54 => wire__crate__api__crypto__verify_cert_impl(port, ptr, rust_vec_len, data_len), + 54 => wire__crate__api__discovery__start_discovery_impl(port, ptr, rust_vec_len, data_len), + 55 => wire__crate__api__server__start_server_impl(port, ptr, rust_vec_len, data_len), + 56 => wire__crate__api__crypto__verify_cert_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -5063,6 +5125,8 @@ fn pde_ffi_dispatcher_sync_impl( 6 => wire__crate__api__cancel__RsCancellationToken_cancel_impl(ptr, rust_vec_len, data_len), 45 => wire__crate__api__cancel__create_cancellation_token_impl(ptr, rust_vec_len, data_len), 46 => wire__crate__api__http__create_client_impl(ptr, rust_vec_len, data_len), + 52 => wire__crate__api__filename__is_valid_file_name_impl(ptr, rust_vec_len, data_len), + 53 => wire__crate__api__filename__sanitize_file_name_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } diff --git a/packages/localsend_isolates/test/task/server/file_saver_test.dart b/packages/localsend_isolates/test/task/server/file_saver_test.dart index bfd468b8..a31dde4d 100644 --- a/packages/localsend_isolates/test/task/server/file_saver_test.dart +++ b/packages/localsend_isolates/test/task/server/file_saver_test.dart @@ -1,12 +1,17 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:localsend_isolates/rust/frb_generated.dart'; import 'package:localsend_isolates/src/task/server/file_saver.dart'; import 'package:path/path.dart' as p; void main() { late Directory tempDir; + setUpAll(() { + RustLib.initMock(api: _MockRustLibApi()); + }); + setUp(() { tempDir = Directory.systemTemp.createTempSync('file_saver_test'); }); @@ -57,3 +62,12 @@ void main() { ); }); } + +/// The sanitizer lives in the Rust library, which is not loaded in unit tests. +class _MockRustLibApi implements RustLibApi { + @override + String crateApiFilenameSanitizeFileName({required String name}) => name; + + @override + dynamic noSuchMethod(Invocation invocation) => throw UnsupportedError('Not mocked: ${invocation.memberName}'); +}