feat: path sanitizing in core
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-01 17:17:41 +02:00
parent b13bbeb3cd
commit da4464def2
22 changed files with 588 additions and 48 deletions
@@ -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<FileNameInputDialog> {
return false;
}
if (!isValidFilename(input, os: Platform.operatingSystem)) {
if (!isValidFileName(name: input)) {
setState(() {
_errorMessage = t.sanitization.invalid;
});
@@ -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<QuickActionsDialog> 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;
});
-8
View File
@@ -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:
-3
View File
@@ -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
+5 -6
View File
@@ -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() {
+1 -1
View File
@@ -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};
+10 -2
View File
@@ -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"],
+1 -1
View File
@@ -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")]
+388
View File
@@ -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("a<b>c: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:?}"
);
}
}
}
}
+1
View File
@@ -1,2 +1,3 @@
pub mod base64;
pub mod filename;
pub(crate) mod time;
+1
View File
@@ -2,3 +2,4 @@
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
/build/
@@ -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);
@@ -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<RustLibApi, RustLibApiImpl, RustLibWire> {
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<RsHashFileEvent> crateApiCryptoHashFile({String? path, int? fileDescriptor, Uint8List? bytes, required RsCancellationToken cancelToken});
bool crateApiFilenameIsValidFileName({required String name});
String crateApiFilenameSanitizeFileName({required String name});
Future<RsDiscovery> 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<RsDiscovery> 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,
@@ -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';
@@ -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';
@@ -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)]);
-8
View File
@@ -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:
-1
View File
@@ -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
@@ -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())
}
@@ -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;
@@ -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::SseCodec, _>(
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 = <String>::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::SseCodec, _>(
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 = <String>::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!(),
}
}
@@ -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}');
}