mirror of
https://github.com/localsend/localsend.git
synced 2026-08-07 07:14:52 +00:00
feat: path sanitizing in core
This commit is contained in:
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
pub mod base64;
|
||||
pub mod filename;
|
||||
pub(crate) mod time;
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user