mirror of
https://github.com/NotAShelf/stash.git
synced 2026-04-13 06:23:47 +00:00
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I3f17b98ad68f17ebcf9554e5e88f62676a6a6964
273 lines
8.5 KiB
Rust
273 lines
8.5 KiB
Rust
use imagesize::ImageType;
|
|
|
|
/// Detect MIME type of clipboard data. We try binary detection first using
|
|
/// [`imagesize`] followed by a check for text/uri-list for file manager copies
|
|
/// and finally fall back to text/plain for UTF-8 or [`None`] for binary.
|
|
pub fn detect_mime(data: &[u8]) -> Option<String> {
|
|
if data.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
// Try image detection first
|
|
if let Ok(img_type) = imagesize::image_type(data) {
|
|
return Some(image_type_to_mime(img_type));
|
|
}
|
|
|
|
// Check if it's UTF-8 text
|
|
if let Ok(text) = std::str::from_utf8(data) {
|
|
let trimmed = text.trim();
|
|
|
|
// Check for text/uri-list format (file paths from file managers)
|
|
if is_uri_list(trimmed) {
|
|
return Some("text/uri-list".to_string());
|
|
}
|
|
|
|
// Default to plain text
|
|
return Some("text/plain".to_string());
|
|
}
|
|
|
|
// Unknown binary data
|
|
None
|
|
}
|
|
|
|
/// Convert [`imagesize`] [`ImageType`] to MIME type string
|
|
fn image_type_to_mime(img_type: ImageType) -> String {
|
|
let mime = match img_type {
|
|
ImageType::Png => "image/png",
|
|
ImageType::Jpeg => "image/jpeg",
|
|
ImageType::Gif => "image/gif",
|
|
ImageType::Bmp => "image/bmp",
|
|
ImageType::Tiff => "image/tiff",
|
|
ImageType::Webp => "image/webp",
|
|
ImageType::Aseprite => "image/x-aseprite",
|
|
ImageType::Dds => "image/vnd.ms-dds",
|
|
ImageType::Exr => "image/aces",
|
|
ImageType::Farbfeld => "image/farbfeld",
|
|
ImageType::Hdr => "image/vnd.radiance",
|
|
ImageType::Ico => "image/x-icon",
|
|
ImageType::Ilbm => "image/ilbm",
|
|
ImageType::Jxl => "image/jxl",
|
|
ImageType::Ktx2 => "image/ktx2",
|
|
ImageType::Pnm => "image/x-portable-anymap",
|
|
ImageType::Psd => "image/vnd.adobe.photoshop",
|
|
ImageType::Qoi => "image/qoi",
|
|
ImageType::Tga => "image/x-tga",
|
|
ImageType::Vtf => "image/x-vtf",
|
|
ImageType::Heif(imagesize::Compression::Hevc) => "image/heic",
|
|
ImageType::Heif(_) => "image/heif",
|
|
_ => "application/octet-stream",
|
|
};
|
|
mime.to_string()
|
|
}
|
|
|
|
/// Check if text is a URI list per RFC 2483.
|
|
///
|
|
/// Used when copying files from file managers - they provide file paths
|
|
/// as text/uri-list format (`file://` URIs, one per line, `#` for comments).
|
|
fn is_uri_list(text: &str) -> bool {
|
|
if text.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
// Must start with a URI scheme to even consider it
|
|
if !text.starts_with("file://")
|
|
&& !text.starts_with("http://")
|
|
&& !text.starts_with("https://")
|
|
&& !text.starts_with("ftp://")
|
|
&& !text.starts_with('#')
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let lines: Vec<&str> = text.lines().map(str::trim).collect();
|
|
|
|
// Check first non-comment line is a URI
|
|
let first_content =
|
|
lines.iter().find(|l| !l.is_empty() && !l.starts_with('#'));
|
|
|
|
if let Some(line) = first_content {
|
|
line.starts_with("file://")
|
|
|| line.starts_with("http://")
|
|
|| line.starts_with("https://")
|
|
|| line.starts_with("ftp://")
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_empty_data() {
|
|
assert_eq!(detect_mime(b""), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_plain_text() {
|
|
let data = b"Hello, world!";
|
|
assert_eq!(detect_mime(data), Some("text/plain".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_uri_list_single_file() {
|
|
let data = b"file:///home/user/document.pdf";
|
|
assert_eq!(detect_mime(data), Some("text/uri-list".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_uri_list_multiple_files() {
|
|
let data = b"file:///home/user/file1.txt\nfile:///home/user/file2.txt";
|
|
assert_eq!(detect_mime(data), Some("text/uri-list".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_uri_list_with_comments() {
|
|
let data = b"# Comment\nfile:///home/user/file.txt";
|
|
assert_eq!(detect_mime(data), Some("text/uri-list".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_uri_list_http() {
|
|
let data = b"https://example.com/image.png";
|
|
assert_eq!(detect_mime(data), Some("text/uri-list".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_not_uri_list() {
|
|
let data = b"This is just text with file:// in the middle";
|
|
assert_eq!(detect_mime(data), Some("text/plain".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_unknown_binary() {
|
|
// Binary data that's not UTF-8 and not a known format
|
|
let data = b"\x80\x81\x82\x83\x84\x85\x86\x87";
|
|
assert_eq!(detect_mime(data), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_uri_list_trailing_newline() {
|
|
let data = b"file:///foo\n";
|
|
assert_eq!(detect_mime(data), Some("text/uri-list".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_uri_list_ftp() {
|
|
let data = b"ftp://host/path";
|
|
assert_eq!(detect_mime(data), Some("text/uri-list".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_uri_list_mixed_schemes() {
|
|
let data = b"file:///home/user/doc.pdf\nhttps://example.com/file.zip";
|
|
assert_eq!(detect_mime(data), Some("text/uri-list".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_plain_url_in_text() {
|
|
let data = b"visit http://example.com for info";
|
|
assert_eq!(detect_mime(data), Some("text/plain".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_png_magic_bytes() {
|
|
// Real PNG header: 8-byte signature + minimal IHDR chunk
|
|
let data: &[u8] = &[
|
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
|
0x00, 0x00, 0x00, 0x0D, // IHDR chunk length
|
|
0x49, 0x48, 0x44, 0x52, // "IHDR"
|
|
0x00, 0x00, 0x00, 0x01, // width: 1
|
|
0x00, 0x00, 0x00, 0x01, // height: 1
|
|
0x08, 0x02, // bit depth: 8, color type: 2 (RGB)
|
|
0x00, 0x00, 0x00, // compression, filter, interlace
|
|
0x90, 0x77, 0x53, 0xDE, // CRC
|
|
];
|
|
assert_eq!(detect_mime(data), Some("image/png".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_jpeg_magic_bytes() {
|
|
// JPEG SOI marker + APP0 (JFIF) marker
|
|
let data: &[u8] = &[
|
|
0xFF, 0xD8, 0xFF, 0xE0, // SOI + APP0
|
|
0x00, 0x10, // Length
|
|
0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
|
|
0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
|
|
];
|
|
assert_eq!(detect_mime(data), Some("image/jpeg".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gif_magic_bytes() {
|
|
// GIF89a header
|
|
let data: &[u8] = &[
|
|
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // "GIF89a"
|
|
0x01, 0x00, 0x01, 0x00, // 1x1
|
|
0x80, 0x00, 0x00, // GCT flag, bg, aspect
|
|
];
|
|
assert_eq!(detect_mime(data), Some("image/gif".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_webp_magic_bytes() {
|
|
// RIFF....WEBP header
|
|
let data: &[u8] = &[
|
|
0x52, 0x49, 0x46, 0x46, // "RIFF"
|
|
0x24, 0x00, 0x00, 0x00, // file size
|
|
0x57, 0x45, 0x42, 0x50, // "WEBP"
|
|
0x56, 0x50, 0x38, 0x20, // "VP8 "
|
|
0x18, 0x00, 0x00, 0x00, // chunk size
|
|
0x30, 0x01, 0x00, 0x9D, 0x01, 0x2A, // VP8 bitstream
|
|
0x01, 0x00, 0x01, 0x00, // width/height
|
|
];
|
|
assert_eq!(detect_mime(data), Some("image/webp".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_whitespace_only() {
|
|
let data = b" \n\t ";
|
|
// Valid UTF-8 text, even if only whitespace. [`detect_mime`] doesn't reject
|
|
// it (store_entry rejects it separately). As text it's text/plain.
|
|
assert_eq!(detect_mime(data), Some("text/plain".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_image_type_to_mime_coverage() {
|
|
assert_eq!(image_type_to_mime(ImageType::Png), "image/png");
|
|
assert_eq!(image_type_to_mime(ImageType::Jpeg), "image/jpeg");
|
|
assert_eq!(image_type_to_mime(ImageType::Gif), "image/gif");
|
|
assert_eq!(image_type_to_mime(ImageType::Bmp), "image/bmp");
|
|
assert_eq!(image_type_to_mime(ImageType::Tiff), "image/tiff");
|
|
assert_eq!(image_type_to_mime(ImageType::Webp), "image/webp");
|
|
assert_eq!(image_type_to_mime(ImageType::Aseprite), "image/x-aseprite");
|
|
assert_eq!(image_type_to_mime(ImageType::Dds), "image/vnd.ms-dds");
|
|
assert_eq!(image_type_to_mime(ImageType::Exr), "image/aces");
|
|
assert_eq!(image_type_to_mime(ImageType::Farbfeld), "image/farbfeld");
|
|
assert_eq!(image_type_to_mime(ImageType::Hdr), "image/vnd.radiance");
|
|
assert_eq!(image_type_to_mime(ImageType::Ico), "image/x-icon");
|
|
assert_eq!(image_type_to_mime(ImageType::Ilbm), "image/ilbm");
|
|
assert_eq!(image_type_to_mime(ImageType::Jxl), "image/jxl");
|
|
assert_eq!(image_type_to_mime(ImageType::Ktx2), "image/ktx2");
|
|
assert_eq!(
|
|
image_type_to_mime(ImageType::Pnm),
|
|
"image/x-portable-anymap"
|
|
);
|
|
assert_eq!(
|
|
image_type_to_mime(ImageType::Psd),
|
|
"image/vnd.adobe.photoshop"
|
|
);
|
|
assert_eq!(image_type_to_mime(ImageType::Qoi), "image/qoi");
|
|
assert_eq!(image_type_to_mime(ImageType::Tga), "image/x-tga");
|
|
assert_eq!(image_type_to_mime(ImageType::Vtf), "image/x-vtf");
|
|
assert_eq!(
|
|
image_type_to_mime(ImageType::Heif(imagesize::Compression::Hevc)),
|
|
"image/heic"
|
|
);
|
|
assert_eq!(
|
|
image_type_to_mime(ImageType::Heif(imagesize::Compression::Av1)),
|
|
"image/heif"
|
|
);
|
|
}
|
|
}
|