aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-04 01:07:53 +0100
committersalmonik <salmonik@tuta.io>2026-11-04 01:07:53 +0100
commit69c032f920d518e481da98cb5a495460e753cdf4 (patch)
tree07ddc303fb03d00325c19aa8aa52160511b3538b
parentyt-dlp: ignore configs and stop option injection (diff)
downloadsenator-sperma-69c032f920d518e481da98cb5a495460e753cdf4.tar.gz
senator-sperma-69c032f920d518e481da98cb5a495460e753cdf4.zip
utilities: sanitize filenames and isolate temp operations
-rw-r--r--src/commands/utilities.rs153
1 files changed, 105 insertions, 48 deletions
diff --git a/src/commands/utilities.rs b/src/commands/utilities.rs
index 79f4508..2179ce9 100644
--- a/src/commands/utilities.rs
+++ b/src/commands/utilities.rs
@@ -66,15 +66,47 @@ pub(crate) fn stem_of(filename: &str) -> &str {
66 } 66 }
67} 67}
68 68
69fn temp_path_for(name: &str) -> String { 69/// A safe basename: no directory components, no characters Discord/Copyparty
70 format!("{}/{}", crate::config::TEMP_PATH, name) 70/// filenames commonly abuse, and never `.`/`..`.
71fn safe_basename(name: &str) -> String {
72 // Treat both separators as directory boundaries: Discord filenames may
73 // come from Windows clients even though the bot runs on Linux.
74 let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
75 let cleaned = sanitize_filename(base)
76 .trim_matches(|c| c == '.' || c == ' ')
77 .to_string();
78 if cleaned.is_empty() {
79 "file".to_string()
80 } else {
81 cleaned
82 }
71} 83}
72 84
73/// Best-effort removal of temp files; ignores missing files. 85/// Create a unique per-operation directory and return
74async fn cleanup(paths: &[&str]) { 86/// `(directory, input_path, output_path)`. Concurrent commands can never
75 for p in paths { 87/// overwrite or delete each other's files.
76 let _ = tokio::fs::remove_file(p).await; 88async fn temp_op(
77 } 89 original: &str,
90 output_name: &str,
91) -> std::io::Result<(String, String, String)> {
92 let safe = safe_basename(original);
93 let ext = ext_of(&safe)
94 .map(|ext| format!(".{ext}"))
95 .unwrap_or_default();
96 let dir = format!(
97 "{}/op_{}",
98 crate::config::TEMP_PATH,
99 uuid::Uuid::new_v4()
100 );
101 tokio::fs::create_dir_all(&dir).await?;
102 let input_path = format!("{dir}/input{ext}");
103 let output_path = format!("{dir}/{}", safe_basename(output_name));
104 Ok((dir, input_path, output_path))
105}
106
107/// Best-effort removal of a per-operation temp directory.
108async fn cleanup_dir(dir: &str) {
109 let _ = tokio::fs::remove_dir_all(dir).await;
78} 110}
79 111
80/// Resolve the input source — either an autocomplete-picked copyparty file or 112/// Resolve the input source — either an autocomplete-picked copyparty file or
@@ -234,35 +266,32 @@ enum DeliverKind {
234/// file, returning the encoded bytes and the output filename. 266/// file, returning the encoded bytes and the output filename.
235/// 267///
236/// Mirrors the per-attachment path of Python's `Compression` message command, 268/// Mirrors the per-attachment path of Python's `Compression` message command,
237/// including sequential `TEMP_PATH` reuse so identical attachment stems don't 269/// but each invocation gets its own temp directory, so concurrent calls cannot
238/// collide (each iteration writes, encodes, then `cleanup`s before the next). 270/// overwrite or delete each other's files.
239async fn compress_attachment_bytes( 271async fn compress_attachment_bytes(
240 ft: FileType, 272 ft: FileType,
241 original: &str, 273 original: &str,
242 input_bytes: &[u8], 274 input_bytes: &[u8],
243) -> Option<(Vec<u8>, String)> { 275) -> Option<(Vec<u8>, String)> {
244 let stem = stem_of(original).to_string(); 276 let safe_original = safe_basename(original);
245 let out_ext = compress_output_ext(ft, original); 277 let stem = stem_of(&safe_original).to_string();
278 let out_ext = compress_output_ext(ft, &safe_original);
246 let out_filename = format!("compressed_{stem}.{out_ext}"); 279 let out_filename = format!("compressed_{stem}.{out_ext}");
247 let input_path = temp_path_for(original); 280 let (dir, input_path, output_path) = temp_op(&safe_original, &out_filename).await.ok()?;
248 let output_path = temp_path_for(&out_filename);
249 let cleanup_paths = [input_path.as_str(), output_path.as_str()];
250 281
251 if tokio::fs::write(&input_path, input_bytes).await.is_err() { 282 if tokio::fs::write(&input_path, input_bytes).await.is_err() {
283 cleanup_dir(&dir).await;
252 return None; 284 return None;
253 } 285 }
254 let mut cmd = match build_compress_command(ft, &input_path, &output_path) { 286 let Some(mut cmd) = build_compress_command(ft, &input_path, &output_path) else {
255 Some(c) => c, 287 cleanup_dir(&dir).await;
256 None => { 288 return None;
257 cleanup(&cleanup_paths).await;
258 return None;
259 }
260 }; 289 };
261 let res = match cmd.output().await { 290 let res = match cmd.output().await {
262 Ok(out) if out.status.success() => tokio::fs::read(&output_path).await.ok(), 291 Ok(out) if out.status.success() => tokio::fs::read(&output_path).await.ok(),
263 _ => None, 292 _ => None,
264 }; 293 };
265 cleanup(&cleanup_paths).await; 294 cleanup_dir(&dir).await;
266 res.map(|b| (b, out_filename)) 295 res.map(|b| (b, out_filename))
267} 296}
268 297
@@ -270,17 +299,14 @@ async fn compress_attachment_bytes(
270/// Uses Vorbis instead of Opus because symphonia 0.6 lacks an Opus decoder. 299/// Uses Vorbis instead of Opus because symphonia 0.6 lacks an Opus decoder.
271/// Returns the compressed .ogg bytes on success. 300/// Returns the compressed .ogg bytes on success.
272pub(crate) async fn compress_audio_bytes(input_bytes: &[u8], original_name: &str) -> Option<Vec<u8>> { 301pub(crate) async fn compress_audio_bytes(input_bytes: &[u8], original_name: &str) -> Option<Vec<u8>> {
273 let safe_name = sanitize_filename(original_name); 302 let safe_name = safe_basename(original_name);
274 let safe_name = if safe_name.is_empty() { "audio".to_string() } else { safe_name };
275 let stem = stem_of(&safe_name).to_string(); 303 let stem = stem_of(&safe_name).to_string();
276 let out_filename = format!("compressed_{stem}.ogg"); 304 let out_filename = format!("compressed_{stem}.ogg");
277 let input_path = temp_path_for(&safe_name); 305 let (dir, input_path, output_path) = temp_op(&safe_name, &out_filename).await.ok()?;
278 let output_path = temp_path_for(&out_filename);
279 let cleanup_paths = [input_path.as_str(), output_path.as_str()];
280 306
281 let _ = tokio::fs::create_dir_all(crate::config::TEMP_PATH).await;
282 if let Err(e) = tokio::fs::write(&input_path, input_bytes).await { 307 if let Err(e) = tokio::fs::write(&input_path, input_bytes).await {
283 tracing::warn!("compress_audio_bytes: failed to write input: {e}"); 308 tracing::warn!("compress_audio_bytes: failed to write input: {e}");
309 cleanup_dir(&dir).await;
284 return None; 310 return None;
285 } 311 }
286 tracing::debug!( 312 tracing::debug!(
@@ -324,7 +350,7 @@ pub(crate) async fn compress_audio_bytes(input_bytes: &[u8], original_name: &str
324 None 350 None
325 } 351 }
326 }; 352 };
327 cleanup(&cleanup_paths).await; 353 cleanup_dir(&dir).await;
328 res 354 res
329} 355}
330 356
@@ -608,16 +634,20 @@ pub async fn compress(
608 None => return Ok(()), 634 None => return Ok(()),
609 }; 635 };
610 636
611 let ft = file_type(&original_filename); 637 let safe_original = safe_basename(&original_filename);
612 let stem = stem_of(&original_filename).to_string(); 638 let ft = file_type(&safe_original);
613 let out_ext = compress_output_ext(ft, &original_filename); 639 let stem = stem_of(&safe_original).to_string();
640 let out_ext = compress_output_ext(ft, &safe_original);
614 let out_filename = format!("compressed_{stem}.{out_ext}"); 641 let out_filename = format!("compressed_{stem}.{out_ext}");
615 let input_path = temp_path_for(&original_filename); 642 let Some((dir, input_path, output_path)) = temp_op(&safe_original, &out_filename).await.ok()
616 let output_path = temp_path_for(&out_filename); 643 else {
617 let cleanup_paths = [input_path.as_str(), output_path.as_str()]; 644 ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?;
645 return Ok(());
646 };
618 647
619 if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await { 648 if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await {
620 tracing::warn!("compress: failed to write input: {e}"); 649 tracing::warn!("compress: failed to write input: {e}");
650 cleanup_dir(&dir).await;
621 ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; 651 ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?;
622 return Ok(()); 652 return Ok(());
623 } 653 }
@@ -625,7 +655,7 @@ pub async fn compress(
625 let result = match build_compress_command(ft, &input_path, &output_path) { 655 let result = match build_compress_command(ft, &input_path, &output_path) {
626 Some(mut cmd) => cmd.output().await, 656 Some(mut cmd) => cmd.output().await,
627 None => { 657 None => {
628 cleanup(&cleanup_paths).await; 658 cleanup_dir(&dir).await;
629 ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?; 659 ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?;
630 return Ok(()); 660 return Ok(());
631 } 661 }
@@ -634,7 +664,7 @@ pub async fn compress(
634 match result { 664 match result {
635 Ok(out) if out.status.success() => match tokio::fs::read(&output_path).await { 665 Ok(out) if out.status.success() => match tokio::fs::read(&output_path).await {
636 Ok(out_bytes) => { 666 Ok(out_bytes) => {
637 cleanup(&cleanup_paths).await; 667 cleanup_dir(&dir).await;
638 deliver( 668 deliver(
639 &ctx, 669 &ctx,
640 out_bytes, 670 out_bytes,
@@ -646,12 +676,12 @@ pub async fn compress(
646 } 676 }
647 Err(e) => { 677 Err(e) => {
648 tracing::warn!("compress: output missing after encode: {e}"); 678 tracing::warn!("compress: output missing after encode: {e}");
649 cleanup(&cleanup_paths).await; 679 cleanup_dir(&dir).await;
650 ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; 680 ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?;
651 } 681 }
652 }, 682 },
653 _ => { 683 _ => {
654 cleanup(&cleanup_paths).await; 684 cleanup_dir(&dir).await;
655 ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; 685 ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?;
656 } 686 }
657 } 687 }
@@ -689,15 +719,19 @@ pub async fn convert(
689 return Ok(()); 719 return Ok(());
690 } 720 }
691 721
692 let ft = file_type(&original_filename); 722 let safe_original = safe_basename(&original_filename);
693 let stem = stem_of(&original_filename).to_string(); 723 let ft = file_type(&safe_original);
724 let stem = stem_of(&safe_original).to_string();
694 let out_filename = format!("{stem}.{fmt}"); 725 let out_filename = format!("{stem}.{fmt}");
695 let input_path = temp_path_for(&original_filename); 726 let Some((dir, input_path, output_path)) = temp_op(&safe_original, &out_filename).await.ok()
696 let output_path = temp_path_for(&out_filename); 727 else {
697 let cleanup_paths = [input_path.as_str(), output_path.as_str()]; 728 ctx.say(lang::L_UTIL_CONVERT_FAILED).await?;
729 return Ok(());
730 };
698 731
699 if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await { 732 if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await {
700 tracing::warn!("convert: failed to write input: {e}"); 733 tracing::warn!("convert: failed to write input: {e}");
734 cleanup_dir(&dir).await;
701 ctx.say(lang::L_UTIL_CONVERT_FAILED).await?; 735 ctx.say(lang::L_UTIL_CONVERT_FAILED).await?;
702 return Ok(()); 736 return Ok(());
703 } 737 }
@@ -714,7 +748,7 @@ pub async fn convert(
714 c 748 c
715 } 749 }
716 FileType::Unknown => { 750 FileType::Unknown => {
717 cleanup(&cleanup_paths).await; 751 cleanup_dir(&dir).await;
718 ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?; 752 ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?;
719 return Ok(()); 753 return Ok(());
720 } 754 }
@@ -723,7 +757,7 @@ pub async fn convert(
723 match cmd.output().await { 757 match cmd.output().await {
724 Ok(out) if out.status.success() => match tokio::fs::read(&output_path).await { 758 Ok(out) if out.status.success() => match tokio::fs::read(&output_path).await {
725 Ok(out_bytes) => { 759 Ok(out_bytes) => {
726 cleanup(&cleanup_paths).await; 760 cleanup_dir(&dir).await;
727 deliver( 761 deliver(
728 &ctx, 762 &ctx,
729 out_bytes, 763 out_bytes,
@@ -735,17 +769,17 @@ pub async fn convert(
735 } 769 }
736 Err(e) => { 770 Err(e) => {
737 tracing::warn!("convert: output missing after encode: {e}"); 771 tracing::warn!("convert: output missing after encode: {e}");
738 cleanup(&cleanup_paths).await; 772 cleanup_dir(&dir).await;
739 ctx.say(lang::L_UTIL_CONVERT_FAILED).await?; 773 ctx.say(lang::L_UTIL_CONVERT_FAILED).await?;
740 } 774 }
741 }, 775 },
742 Ok(out) => { 776 Ok(out) => {
743 let stderr = String::from_utf8_lossy(&out.stderr); 777 let stderr = String::from_utf8_lossy(&out.stderr);
744 cleanup(&cleanup_paths).await; 778 cleanup_dir(&dir).await;
745 ctx.say(lang::fmt_util_convert_err(&stderr)).await?; 779 ctx.say(lang::fmt_util_convert_err(&stderr)).await?;
746 } 780 }
747 Err(e) => { 781 Err(e) => {
748 cleanup(&cleanup_paths).await; 782 cleanup_dir(&dir).await;
749 ctx.say(lang::fmt_util_convert_err(&e.to_string())).await?; 783 ctx.say(lang::fmt_util_convert_err(&e.to_string())).await?;
750 } 784 }
751 } 785 }
@@ -1017,3 +1051,26 @@ pub fn commands() -> Vec<poise::Command<Data, Error>> {
1017 message_download(), 1051 message_download(),
1018 ] 1052 ]
1019} 1053}
1054
1055#[cfg(test)]
1056mod tests {
1057 use super::*;
1058
1059 #[test]
1060 fn safe_basename_strips_directories() {
1061 assert_eq!(safe_basename("/music/foo.mp3"), "foo.mp3");
1062 assert_eq!(safe_basename("../../etc/passwd"), "passwd");
1063 assert_eq!(
1064 safe_basename(r"..\..\windows\system32\evil.exe"),
1065 "evil.exe"
1066 );
1067 assert_eq!(safe_basename(r"C:\Users\x\song.mp3"), "song.mp3");
1068 }
1069
1070 #[test]
1071 fn safe_basename_never_returns_dot_entries() {
1072 assert_eq!(safe_basename(".."), "file");
1073 assert_eq!(safe_basename("."), "file");
1074 assert_eq!(safe_basename(""), "file");
1075 }
1076}