Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 9b6ef5e

Browse files
authoredFeb 13, 2025··
fix: get_filename() is now guaranteed to return a valid filename (#6537)
With iOS and Desktop copying the file to a to a temp file with the name of `get_filename()`, it should be sanitized first. The PR can be reviewed commit-by-commit or all at once.
1 parent 81e9628 commit 9b6ef5e

File tree

6 files changed

+124
-50
lines changed

6 files changed

+124
-50
lines changed
 

‎src/blob.rs

Lines changed: 9 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use crate::constants::{self, MediaQuality};
2323
use crate::context::Context;
2424
use crate::events::EventType;
2525
use crate::log::LogExt;
26+
use crate::tools::sanitize_filename;
2627

2728
/// Represents a file in the blob directory.
2829
///
@@ -92,7 +93,7 @@ impl<'a> BlobObject<'a> {
9293
let mut src_file = fs::File::open(src)
9394
.await
9495
.with_context(|| format!("failed to open file {}", src.display()))?;
95-
let (stem, ext) = BlobObject::sanitise_name(&src.to_string_lossy());
96+
let (stem, ext) = BlobObject::sanitize_name_and_split_extension(&src.to_string_lossy());
9697
let (name, mut dst_file) =
9798
BlobObject::create_new_file(context, context.get_blobdir(), &stem, &ext).await?;
9899
let name_for_err = name.clone();
@@ -159,10 +160,9 @@ impl<'a> BlobObject<'a> {
159160
let hash = hash.get(0..31).unwrap_or(hash);
160161
let new_file =
161162
if let Some(extension) = original_name.extension().filter(|e| e.len() <= 32) {
162-
format!(
163-
"$BLOBDIR/{hash}.{}",
164-
extension.to_string_lossy().to_lowercase()
165-
)
163+
let extension = extension.to_string_lossy().to_lowercase();
164+
let extension = sanitize_filename(&extension);
165+
format!("$BLOBDIR/{hash}.{}", extension)
166166
} else {
167167
format!("$BLOBDIR/{hash}")
168168
};
@@ -215,7 +215,7 @@ impl<'a> BlobObject<'a> {
215215
/// the file will be copied into the blob directory first. If the
216216
/// source file is already in the blobdir it will not be copied
217217
/// and only be created if it is a valid blobname, that is no
218-
/// subdirectory is used and [BlobObject::sanitise_name] does not
218+
/// subdirectory is used and [BlobObject::sanitize_name_and_split_extension] does not
219219
/// modify the filename.
220220
///
221221
/// Paths into the blob directory may be either defined by an absolute path
@@ -311,41 +311,14 @@ impl<'a> BlobObject<'a> {
311311
}
312312
}
313313

314-
/// Create a safe name based on a messy input string.
315-
///
316-
/// The safe name will be a valid filename on Unix and Windows and
317-
/// not contain any path separators. The input can contain path
318-
/// segments separated by either Unix or Windows path separators,
319-
/// the rightmost non-empty segment will be used as name,
320-
/// sanitised for special characters.
321-
///
322-
/// The resulting name is returned as a tuple, the first part
314+
/// The name is returned as a tuple, the first part
323315
/// being the stem or basename and the second being an extension,
324316
/// including the dot. E.g. "foo.txt" is returned as `("foo",
325317
/// ".txt")` while "bar" is returned as `("bar", "")`.
326318
///
327319
/// The extension part will always be lowercased.
328-
fn sanitise_name(name: &str) -> (String, String) {
329-
let mut name = name;
330-
for part in name.rsplit('/') {
331-
if !part.is_empty() {
332-
name = part;
333-
break;
334-
}
335-
}
336-
for part in name.rsplit('\\') {
337-
if !part.is_empty() {
338-
name = part;
339-
break;
340-
}
341-
}
342-
let opts = sanitize_filename::Options {
343-
truncate: true,
344-
windows: true,
345-
replacement: "",
346-
};
347-
348-
let name = sanitize_filename::sanitize_with_options(name, opts);
320+
fn sanitize_name_and_split_extension(name: &str) -> (String, String) {
321+
let name = sanitize_filename(name);
349322
// Let's take a tricky filename,
350323
// "file.with_lots_of_characters_behind_point_and_double_ending.tar.gz" as an example.
351324
// Assume that the extension is 32 chars maximum.

‎src/blob/blob_tests.rs

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -181,30 +181,33 @@ fn test_is_blob_name() {
181181

182182
#[test]
183183
fn test_sanitise_name() {
184-
let (stem, ext) = BlobObject::sanitise_name("Я ЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯ.txt");
184+
let (stem, ext) = BlobObject::sanitize_name_and_split_extension(
185+
"Я ЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯ.txt",
186+
);
185187
assert_eq!(ext, ".txt");
186188
assert!(!stem.is_empty());
187189

188190
// the extensions are kept together as between stem and extension a number may be added -
189191
// and `foo.tar.gz` should become `foo-1234.tar.gz` and not `foo.tar-1234.gz`
190-
let (stem, ext) = BlobObject::sanitise_name("wot.tar.gz");
192+
let (stem, ext) = BlobObject::sanitize_name_and_split_extension("wot.tar.gz");
191193
assert_eq!(stem, "wot");
192194
assert_eq!(ext, ".tar.gz");
193195

194-
let (stem, ext) = BlobObject::sanitise_name(".foo.bar");
195-
assert_eq!(stem, "");
196+
let (stem, ext) = BlobObject::sanitize_name_and_split_extension(".foo.bar");
197+
assert_eq!(stem, "file");
196198
assert_eq!(ext, ".foo.bar");
197199

198-
let (stem, ext) = BlobObject::sanitise_name("foo?.bar");
200+
let (stem, ext) = BlobObject::sanitize_name_and_split_extension("foo?.bar");
199201
assert!(stem.contains("foo"));
200202
assert!(!stem.contains('?'));
201203
assert_eq!(ext, ".bar");
202204

203-
let (stem, ext) = BlobObject::sanitise_name("no-extension");
205+
let (stem, ext) = BlobObject::sanitize_name_and_split_extension("no-extension");
204206
assert_eq!(stem, "no-extension");
205207
assert_eq!(ext, "");
206208

207-
let (stem, ext) = BlobObject::sanitise_name("path/ignored\\this: is* forbidden?.c");
209+
let (stem, ext) =
210+
BlobObject::sanitize_name_and_split_extension("path/ignored\\this: is* forbidden?.c");
208211
assert_eq!(ext, ".c");
209212
assert!(!stem.contains("path"));
210213
assert!(!stem.contains("ignored"));
@@ -216,7 +219,7 @@ fn test_sanitise_name() {
216219
assert!(!stem.contains('*'));
217220
assert!(!stem.contains('?'));
218221

219-
let (stem, ext) = BlobObject::sanitise_name(
222+
let (stem, ext) = BlobObject::sanitize_name_and_split_extension(
220223
"file.with_lots_of_characters_behind_point_and_double_ending.tar.gz",
221224
);
222225
assert_eq!(
@@ -225,11 +228,11 @@ fn test_sanitise_name() {
225228
);
226229
assert_eq!(ext, ".tar.gz");
227230

228-
let (stem, ext) = BlobObject::sanitise_name("a. tar.tar.gz");
231+
let (stem, ext) = BlobObject::sanitize_name_and_split_extension("a. tar.tar.gz");
229232
assert_eq!(stem, "a. tar");
230233
assert_eq!(ext, ".tar.gz");
231234

232-
let (stem, ext) = BlobObject::sanitise_name("Guia_uso_GNB (v0.8).pdf");
235+
let (stem, ext) = BlobObject::sanitize_name_and_split_extension("Guia_uso_GNB (v0.8).pdf");
233236
assert_eq!(stem, "Guia_uso_GNB (v0.8)");
234237
assert_eq!(ext, ".pdf");
235238
}

‎src/message.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ use crate::reaction::get_msg_reactions;
3232
use crate::sql;
3333
use crate::summary::Summary;
3434
use crate::tools::{
35-
buf_compress, buf_decompress, get_filebytes, get_filemeta, gm2local_offset, read_file, time,
36-
timestamp_to_str, truncate,
35+
buf_compress, buf_decompress, get_filebytes, get_filemeta, gm2local_offset, read_file,
36+
sanitize_filename, time, timestamp_to_str, truncate,
3737
};
3838

3939
/// Message ID, including reserved IDs.
@@ -807,12 +807,12 @@ impl Message {
807807
/// To get the full path, use [`Self::get_file()`].
808808
pub fn get_filename(&self) -> Option<String> {
809809
if let Some(name) = self.param.get(Param::Filename) {
810-
return Some(name.to_string());
810+
return Some(sanitize_filename(name));
811811
}
812812
self.param
813813
.get(Param::File)
814814
.and_then(|file| Path::new(file).file_name())
815-
.map(|name| name.to_string_lossy().to_string())
815+
.map(|name| sanitize_filename(&name.to_string_lossy()))
816816
}
817817

818818
/// Returns the size of the file in bytes, if applicable.

‎src/message/message_tests.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,3 +755,31 @@ async fn test_delete_msgs_offline() -> Result<()> {
755755

756756
Ok(())
757757
}
758+
759+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
760+
async fn test_sanitize_filename_message() -> Result<()> {
761+
let t = &TestContext::new().await;
762+
let mut msg = Message::new(Viewtype::File);
763+
764+
// Even if some of these characters may be valid on one platform,
765+
// they need to be removed in case a backup is transferred to another platform
766+
// and the UI there tries to copy the blob to a file with the original name
767+
// before passing it to an external program.
768+
msg.set_file_from_bytes(t, "/\\:ee.tx*T ", b"hallo", None)?;
769+
assert_eq!(msg.get_filename().unwrap(), "ee.txT");
770+
771+
let blob = msg.param.get_blob(Param::File, t).await?.unwrap();
772+
assert_eq!(blob.suffix().unwrap(), "txt");
773+
774+
// The filename shouldn't be empty if there were only illegal characters:
775+
msg.set_file_from_bytes(t, "/\\:.txt", b"hallo", None)?;
776+
assert_eq!(msg.get_filename().unwrap(), "file.txt");
777+
778+
msg.set_file_from_bytes(t, "/\\:", b"hallo", None)?;
779+
assert_eq!(msg.get_filename().unwrap(), "file");
780+
781+
msg.set_file_from_bytes(t, ".txt", b"hallo", None)?;
782+
assert_eq!(msg.get_filename().unwrap(), "file.txt");
783+
784+
Ok(())
785+
}

‎src/receive_imf/receive_imf_tests.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5582,3 +5582,38 @@ async fn test_two_group_securejoins() -> Result<()> {
55825582

55835583
Ok(())
55845584
}
5585+
5586+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5587+
async fn test_sanitize_filename_in_received() -> Result<()> {
5588+
let alice = &TestContext::new_alice().await;
5589+
let raw = b"Message-ID: Mr.XA6y3og8-az.WGbH9_dNcQx@testr
5590+
To: <tmp_5890965001269692@testrun.org>
5591+
From: \"=?utf-8?q??=\" <tmp_6272287793210918@testrun.org>
5592+
Content-Type: multipart/mixed; boundary=\"mwkNRwaJw1M5n2xcr2ODfAqvTjcj9Z\"
5593+
5594+
5595+
--mwkNRwaJw1M5n2xcr2ODfAqvTjcj9Z
5596+
Content-Type: text/plain; charset=utf-8
5597+
5598+
--
5599+
Sent with my Delta Chat Messenger: https://delta.chat
5600+
5601+
--mwkNRwaJw1M5n2xcr2ODfAqvTjcj9Z
5602+
Content-Type: text/html
5603+
Content-Disposition: attachment; filename=\"te\xE2\x80\xACst/../../test.H|TML\xE2\x80\xAC \"
5604+
Content-Transfer-Encoding: base64
5605+
5606+
PGh0bWw+PGJvZHk+dGV4dDwvYm9keT5kYXRh
5607+
5608+
--mwkNRwaJw1M5n2xcr2ODfAqvTjcj9Z--";
5609+
5610+
let msg = receive_imf(alice, raw, false).await?.unwrap();
5611+
let msg = Message::load_from_db(alice, msg.msg_ids[0]).await?;
5612+
5613+
assert_eq!(msg.get_filename().unwrap(), "test.HTML");
5614+
5615+
let blob = msg.param.get_blob(Param::File, alice).await?.unwrap();
5616+
assert_eq!(blob.suffix().unwrap(), "html");
5617+
5618+
Ok(())
5619+
}

‎src/tools.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,41 @@ pub(crate) async fn delete_file(context: &Context, path: impl AsRef<Path>) -> Re
366366
Ok(())
367367
}
368368

369+
/// Create a safe name based on a messy input string.
370+
///
371+
/// The safe name will be a valid filename on Unix and Windows and
372+
/// not contain any path separators. The input can contain path
373+
/// segments separated by either Unix or Windows path separators,
374+
/// the rightmost non-empty segment will be used as name,
375+
/// sanitised for special characters.
376+
pub(crate) fn sanitize_filename(mut name: &str) -> String {
377+
for part in name.rsplit('/') {
378+
if !part.is_empty() {
379+
name = part;
380+
break;
381+
}
382+
}
383+
for part in name.rsplit('\\') {
384+
if !part.is_empty() {
385+
name = part;
386+
break;
387+
}
388+
}
389+
390+
let opts = sanitize_filename::Options {
391+
truncate: true,
392+
windows: true,
393+
replacement: "",
394+
};
395+
let name = sanitize_filename::sanitize_with_options(name, opts);
396+
397+
if name.starts_with('.') || name.is_empty() {
398+
format!("file{name}")
399+
} else {
400+
name
401+
}
402+
}
403+
369404
/// A guard which will remove the path when dropped.
370405
///
371406
/// It implements [`Deref`] so it can be used as a `&Path`.

0 commit comments

Comments
 (0)
Please sign in to comment.