// Developed by Manfred Lotz in cooperation with Claude (Anthropic). use crate::detector::DetectResult; use anyhow::Result; use std::fmt; use std::os::unix::fs::PermissionsExt; use std::path::Path; pub struct CheckResult { pub mime_issue: Option, pub perm_issue: Option, /// true when the extension is not in our whitelist — for --verbose display pub unclassified: bool, } pub enum Issue { MimeMismatch { expected: &'static str, got: DetectResult, }, PermMismatch { expected: &'static str, got: u32, }, } impl fmt::Display for Issue { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Issue::MimeMismatch { expected, got } => { write!(f, "MIME mismatch — expected {expected}, got {got}") } Issue::PermMismatch { expected, got } => { write!( f, "permission mismatch — expected {expected}, got {:03o}", got ) } } } } enum KnownExt { Pdf, PostScript, Zip, Archive, Image, Font, Text, WindowsScript, } fn extension_kind(ext: &str) -> Option { match ext { "pdf" => Some(KnownExt::Pdf), "ps" | "eps" => Some(KnownExt::PostScript), "zip" => Some(KnownExt::Zip), "tar" | "tgz" | "txz" | "gz" | "bz2" | "xz" | "zst" | "7z" | "rar" | "lz" | "lzma" | "deb" | "rpm" => Some(KnownExt::Archive), "png" | "jpg" | "jpeg" | "gif" | "webp" | "tiff" | "tif" | "bmp" | "ico" => { Some(KnownExt::Image) } "otf" | "ttf" | "woff" | "woff2" => Some(KnownExt::Font), "txt" | "md" | "rst" | "csv" | "toml" | "yaml" | "yml" | "sh" | "bash" | "zsh" | "fish" | "py" | "rb" | "pl" | "js" | "ts" | "c" | "h" | "cpp" | "hpp" | "rs" | "tex" | "sty" | "cls" | "bib" | "dtx" | "ins" | "ltx" | "def" | "fd" | "bst" | "go" | "java" | "cs" | "kt" | "swift" | "zig" | "lua" | "php" | "r" | "jl" | "hs" | "ex" | "exs" | "dart" | "scala" | "html" | "htm" | "css" | "scss" | "less" | "svg" | "json" | "xml" | "ini" | "sql" | "tsv" | "env" | "nix" | "org" | "adoc" | "cmake" | "mk" => Some(KnownExt::Text), "cmd" | "bat" | "ps1" => Some(KnownExt::WindowsScript), _ => None, } } fn expected_mime(kind: &KnownExt) -> &'static str { match kind { KnownExt::Pdf => "PDF", KnownExt::PostScript => "PostScript", KnownExt::Zip => "ZIP", KnownExt::Archive => "archive", KnownExt::Image => "image", KnownExt::Font => "font", KnownExt::Text => "text", KnownExt::WindowsScript => "text", } } fn mime_matches(result: &DetectResult, kind: &KnownExt) -> bool { match (kind, result) { (KnownExt::Pdf, DetectResult::Pdf) => true, (KnownExt::PostScript, DetectResult::PostScript) => true, (KnownExt::Zip, DetectResult::Zip) => true, (KnownExt::Archive, DetectResult::Archive) => true, (KnownExt::Archive, DetectResult::Mime(m)) => matches!( m.as_str(), "application/x-7z-compressed" | "application/x-rar-compressed" | "application/vnd.rar" | "application/x-lzip" | "application/x-lzma" | "application/vnd.debian.binary-package" | "application/x-deb" | "application/x-rpm" ), (KnownExt::Image, DetectResult::Png) => true, (KnownExt::Image, DetectResult::Mime(m)) => m.starts_with("image/"), (KnownExt::Font, DetectResult::Mime(m)) => { m.starts_with("font/") || matches!( m.as_str(), "application/font-sfnt" | "application/font-woff" ) } ( KnownExt::Text, DetectResult::Text(_) | DetectResult::Script(_, _) | DetectResult::Bom(_), ) => true, (KnownExt::Text, DetectResult::Mime(m)) => { m.starts_with("text/") || matches!( m.as_str(), "application/json" | "application/xml" | "application/javascript" | "image/svg+xml" ) } ( KnownExt::WindowsScript, DetectResult::Text(_) | DetectResult::Script(_, _) | DetectResult::Bom(_), ) => true, (KnownExt::WindowsScript, DetectResult::Mime(m)) => { m.starts_with("text/") || matches!( m.as_str(), "application/json" | "application/xml" | "application/javascript" | "image/svg+xml" ) } _ => false, } } fn is_executable(result: &DetectResult) -> bool { matches!(result, DetectResult::Elf) } pub fn check_file(path: &Path, result: &DetectResult) -> Result { if matches!(result, DetectResult::Directory) { let mode = path.metadata()?.permissions().mode() & 0o777; let perm_issue = ((mode & 0o755) != 0o755).then_some(Issue::PermMismatch { expected: "at least 755", got: mode, }); return Ok(CheckResult { mime_issue: None, perm_issue, unclassified: false, }); } let ext = path.extension().and_then(|s| s.to_str()).unwrap_or(""); let kind = extension_kind(ext); let unclassified = kind.is_none(); let mime_issue = if let Some(ref k) = kind && !mime_matches(result, k) { Some(Issue::MimeMismatch { expected: expected_mime(k), got: result.clone(), }) } else { None }; let mode = path.metadata()?.permissions().mode() & 0o777; let perm_issue = if is_executable(result) { ((mode & 0o755) != 0o755).then_some(Issue::PermMismatch { expected: "at least 755", got: mode, }) } else if matches!(result, DetectResult::Script(_, _)) || matches!(kind, Some(KnownExt::WindowsScript)) { ((mode & 0o644) != 0o644).then_some(Issue::PermMismatch { expected: "at least 644", got: mode, }) } else { ((mode & 0o111) != 0 || (mode & 0o644) != 0o644).then_some(Issue::PermMismatch { expected: "at least 644, no execute bits", got: mode, }) }; Ok(CheckResult { mime_issue, perm_issue, unclassified, }) } #[cfg(test)] mod tests { use super::*; use crate::detector::LineEnding; use std::fs; use std::os::unix::fs::PermissionsExt; fn make_file(name: &str, content: &[u8], mode: u32) -> (tempfile::TempDir, std::path::PathBuf) { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join(name); fs::write(&path, content).unwrap(); fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap(); (dir, path) } fn make_dir(mode: u32) -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); fs::set_permissions(dir.path(), fs::Permissions::from_mode(mode)).unwrap(); dir } // ── MIME checks ────────────────────────────────────────────────────────── #[test] fn pdf_correct_type_and_perms() { let (_dir, path) = make_file("report.pdf", b"content", 0o644); let cr = check_file(&path, &DetectResult::Pdf).unwrap(); assert!(cr.mime_issue.is_none()); assert!(cr.perm_issue.is_none()); assert!(!cr.unclassified); } #[test] fn pdf_mime_mismatch() { let (_dir, path) = make_file("report.pdf", b"content", 0o644); let cr = check_file(&path, &DetectResult::Text(LineEnding::Lf)).unwrap(); assert!(matches!(cr.mime_issue, Some(Issue::MimeMismatch { .. }))); assert!(cr.perm_issue.is_none()); } #[test] fn zip_correct() { let (_dir, path) = make_file("archive.zip", b"content", 0o644); let cr = check_file(&path, &DetectResult::Zip).unwrap(); assert!(cr.mime_issue.is_none()); assert!(cr.perm_issue.is_none()); } #[test] fn zip_mime_mismatch() { let (_dir, path) = make_file("archive.zip", b"content", 0o644); let cr = check_file(&path, &DetectResult::Archive).unwrap(); assert!(matches!(cr.mime_issue, Some(Issue::MimeMismatch { .. }))); assert!(cr.perm_issue.is_none()); } // ── permission checks ──────────────────────────────────────────────────── #[test] fn script_with_correct_perms() { let (_dir, path) = make_file("deploy.sh", b"content", 0o755); let cr = check_file( &path, &DetectResult::Script(LineEnding::Lf, "/bin/sh".into()), ) .unwrap(); assert!(cr.mime_issue.is_none()); assert!(cr.perm_issue.is_none()); } #[test] fn script_with_644_perms_ok() { let (_dir, path) = make_file("deploy.sh", b"content", 0o644); let cr = check_file( &path, &DetectResult::Script(LineEnding::Lf, "/bin/sh".into()), ) .unwrap(); assert!(cr.mime_issue.is_none()); assert!(cr.perm_issue.is_none()); } #[test] fn script_with_bad_perms() { let (_dir, path) = make_file("deploy.sh", b"content", 0o600); let cr = check_file( &path, &DetectResult::Script(LineEnding::Lf, "/bin/sh".into()), ) .unwrap(); assert!(cr.mime_issue.is_none()); assert!(matches!(cr.perm_issue, Some(Issue::PermMismatch { .. }))); } #[test] fn elf_with_correct_perms() { let (_dir, path) = make_file("binary", b"\x7fELF", 0o755); let cr = check_file(&path, &DetectResult::Elf).unwrap(); assert!(cr.mime_issue.is_none()); assert!(cr.perm_issue.is_none()); } #[test] fn elf_missing_execute_bit() { let (_dir, path) = make_file("binary", b"\x7fELF", 0o644); let cr = check_file(&path, &DetectResult::Elf).unwrap(); assert!(cr.mime_issue.is_none()); assert!(matches!(cr.perm_issue, Some(Issue::PermMismatch { .. }))); } #[test] fn text_file_with_execute_bit() { let (_dir, path) = make_file("lib.rs", b"fn main() {}", 0o755); let cr = check_file(&path, &DetectResult::Text(LineEnding::Lf)).unwrap(); assert!(cr.mime_issue.is_none()); assert!(matches!(cr.perm_issue, Some(Issue::PermMismatch { .. }))); } #[test] fn text_file_missing_read_bit() { let (_dir, path) = make_file("lib.rs", b"fn main() {}", 0o200); let cr = check_file(&path, &DetectResult::Text(LineEnding::Lf)).unwrap(); assert!(cr.mime_issue.is_none()); assert!(matches!(cr.perm_issue, Some(Issue::PermMismatch { .. }))); } // ── image checks ──────────────────────────────────────────────────────── #[test] fn png_file_as_image_kind() { let (_dir, path) = make_file("photo.png", b"content", 0o644); let cr = check_file(&path, &DetectResult::Png).unwrap(); assert!(cr.mime_issue.is_none()); assert!(cr.perm_issue.is_none()); } #[test] fn jpg_file_with_image_mime() { let (_dir, path) = make_file("photo.jpg", b"content", 0o644); let cr = check_file(&path, &DetectResult::Mime("image/jpeg".into())).unwrap(); assert!(cr.mime_issue.is_none()); assert!(cr.perm_issue.is_none()); } #[test] fn jpg_file_mime_mismatch() { let (_dir, path) = make_file("photo.jpg", b"content", 0o644); let cr = check_file(&path, &DetectResult::Text(LineEnding::Lf)).unwrap(); assert!(matches!(cr.mime_issue, Some(Issue::MimeMismatch { .. }))); } // ── font checks ────────────────────────────────────────────────────────── #[test] fn ttf_correct() { let (_dir, path) = make_file("font.ttf", b"content", 0o644); let cr = check_file(&path, &DetectResult::Mime("application/font-sfnt".into())).unwrap(); assert!(cr.mime_issue.is_none()); assert!(cr.perm_issue.is_none()); } #[test] fn woff_correct() { let (_dir, path) = make_file("font.woff", b"content", 0o644); let cr = check_file(&path, &DetectResult::Mime("application/font-woff".into())).unwrap(); assert!(cr.mime_issue.is_none()); assert!(cr.perm_issue.is_none()); } #[test] fn ttf_mime_mismatch() { let (_dir, path) = make_file("font.ttf", b"content", 0o644); let cr = check_file(&path, &DetectResult::Text(LineEnding::Lf)).unwrap(); assert!(matches!(cr.mime_issue, Some(Issue::MimeMismatch { .. }))); } // ── extended archive checks ────────────────────────────────────────────── #[test] fn archive_7z_correct() { let (_dir, path) = make_file("data.7z", b"content", 0o644); let cr = check_file( &path, &DetectResult::Mime("application/x-7z-compressed".into()), ) .unwrap(); assert!(cr.mime_issue.is_none()); } #[test] fn archive_rar_correct() { let (_dir, path) = make_file("data.rar", b"content", 0o644); let cr = check_file( &path, &DetectResult::Mime("application/x-rar-compressed".into()), ) .unwrap(); assert!(cr.mime_issue.is_none()); } // ── extended text checks ───────────────────────────────────────────────── #[test] fn json_file_detected_as_text() { let (_dir, path) = make_file("data.json", b"content", 0o644); let cr = check_file(&path, &DetectResult::Text(LineEnding::Lf)).unwrap(); assert!(cr.mime_issue.is_none()); } #[test] fn json_file_detected_as_application_json() { let (_dir, path) = make_file("data.json", b"content", 0o644); let cr = check_file(&path, &DetectResult::Mime("application/json".into())).unwrap(); assert!(cr.mime_issue.is_none()); } #[test] fn svg_detected_as_image_svg_xml() { let (_dir, path) = make_file("figure.svg", b"content", 0o644); let cr = check_file(&path, &DetectResult::Mime("image/svg+xml".into())).unwrap(); assert!(cr.mime_issue.is_none()); } // ── Windows script checks ──────────────────────────────────────────────── #[test] fn cmd_with_644_ok() { let (_dir, path) = make_file("setup.cmd", b"@echo off", 0o644); let cr = check_file(&path, &DetectResult::Text(LineEnding::Lf)).unwrap(); assert!(cr.mime_issue.is_none()); assert!(cr.perm_issue.is_none()); } #[test] fn bat_with_755_ok() { let (_dir, path) = make_file("setup.bat", b"@echo off", 0o755); let cr = check_file(&path, &DetectResult::Text(LineEnding::Lf)).unwrap(); assert!(cr.mime_issue.is_none()); assert!(cr.perm_issue.is_none()); } #[test] fn ps1_with_644_ok() { let (_dir, path) = make_file("deploy.ps1", b"Write-Host hello", 0o644); let cr = check_file(&path, &DetectResult::Text(LineEnding::Lf)).unwrap(); assert!(cr.mime_issue.is_none()); assert!(cr.perm_issue.is_none()); } #[test] fn cmd_with_bad_perms() { let (_dir, path) = make_file("setup.cmd", b"@echo off", 0o600); let cr = check_file(&path, &DetectResult::Text(LineEnding::Lf)).unwrap(); assert!(cr.mime_issue.is_none()); assert!(matches!(cr.perm_issue, Some(Issue::PermMismatch { .. }))); } // ── unclassified extension ─────────────────────────────────────────────── #[test] fn unknown_extension_is_unclassified() { let (_dir, path) = make_file("data.xyz", b"content", 0o644); let cr = check_file(&path, &DetectResult::Text(LineEnding::Lf)).unwrap(); assert!(cr.mime_issue.is_none()); assert!(cr.perm_issue.is_none()); assert!(cr.unclassified); } // ── directory permission checks ────────────────────────────────────────── #[test] fn directory_with_correct_perms() { let dir = make_dir(0o755); let cr = check_file(dir.path(), &DetectResult::Directory).unwrap(); assert!(cr.mime_issue.is_none()); assert!(cr.perm_issue.is_none()); assert!(!cr.unclassified); } #[test] fn directory_missing_execute_bit() { let dir = make_dir(0o644); let cr = check_file(dir.path(), &DetectResult::Directory).unwrap(); assert!(cr.mime_issue.is_none()); assert!(matches!(cr.perm_issue, Some(Issue::PermMismatch { .. }))); } }