diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-04 01:07:53 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-04 01:07:53 +0100 |
| commit | 69c032f920d518e481da98cb5a495460e753cdf4 (patch) | |
| tree | 07ddc303fb03d00325c19aa8aa52160511b3538b | |
| parent | yt-dlp: ignore configs and stop option injection (diff) | |
| download | senator-sperma-69c032f920d518e481da98cb5a495460e753cdf4.tar.gz senator-sperma-69c032f920d518e481da98cb5a495460e753cdf4.zip | |
utilities: sanitize filenames and isolate temp operations
| -rw-r--r-- | src/commands/utilities.rs | 153 |
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 | ||
| 69 | fn 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 `.`/`..`. |
| 71 | fn 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 |
| 74 | async 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; | 88 | async 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. | ||
| 108 | async 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. |
| 239 | async fn compress_attachment_bytes( | 271 | async 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. |
| 272 | pub(crate) async fn compress_audio_bytes(input_bytes: &[u8], original_name: &str) -> Option<Vec<u8>> { | 301 | pub(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)] | ||
| 1056 | mod 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 | } | ||
