1use crate::error::{DocError, Result};
4
5const OLE_MAGIC: &[u8] = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1";
6const ZIP_MAGIC: &[u8] = b"PK\x03\x04";
7const PDF_MAGIC: &[u8] = b"%PDF";
8
9const OOXML: &[&str] = &["docx", "xlsx", "pptx"];
10const ODF: &[&str] = &["odt", "ods", "odp"];
11const OLE_EXT: &[&str] = &["doc", "ppt", "xls"];
12const TEXT_EXT: &[&str] = &["txt", "md", "markdown", "csv", "html", "htm"];
13const SUB_EXT: &[&str] = &["srt", "vtt", "ass", "ssa"];
15const RTF_EXT: &[&str] = &["rtf"];
17
18fn head_of(path: &str, n: usize) -> Result<Vec<u8>> {
19 std::fs::read(path)
20 .map(|v| v[..v.len().min(n)].to_vec())
21 .map_err(|e| DocError::corrupt(format!("cannot read {path}: {e}")))
22}
23
24pub fn sniff(path: &str) -> Result<&'static str> {
29 let ext = path
30 .rsplit('.')
31 .next()
32 .map(|e| e.to_ascii_lowercase())
33 .unwrap_or_default();
34 let h = head_of(path, 8)?;
35
36 if h.starts_with(OLE_MAGIC) {
37 if OOXML.contains(&ext.as_str()) {
38 return Err(DocError::encrypted(format!("{ext} 为加密容器(OLE)")));
39 }
40 if OLE_EXT.contains(&ext.as_str()) {
41 return Ok("ole");
42 }
43 return Err(DocError::unsupported(format!(
44 "OLE 容器但扩展名不支持: {ext}"
45 )));
46 }
47 if h.starts_with(ZIP_MAGIC) {
48 if ext == "epub" {
50 return Ok("epub");
51 }
52 if ODF.contains(&ext.as_str()) {
55 return Ok(match ext.as_str() {
56 "odt" => "odt",
57 "ods" => "ods",
58 _ => "odp",
59 });
60 }
61 if OOXML.contains(&ext.as_str()) {
63 return Ok(match ext.as_str() {
64 "docx" => "docx",
65 "xlsx" => "xlsx",
66 _ => "pptx",
67 });
68 }
69 return Err(DocError::corrupt("ZIP 容器但缺少 OOXML 关键部件"));
70 }
71 if h.starts_with(PDF_MAGIC) {
72 return Ok("pdf");
73 }
74 if RTF_EXT.contains(&ext.as_str()) {
75 return Ok("rtf");
77 }
78 if OLE_EXT.contains(&ext.as_str()) {
79 return Err(DocError::unsupported(format!(
80 "{ext} 文件头不是 OLE,疑似伪装"
81 )));
82 }
83 if TEXT_EXT.contains(&ext.as_str()) {
84 let kind: &'static str = match ext.as_str() {
85 "txt" => "txt",
86 "md" | "markdown" => "md",
87 "csv" => "csv",
88 "html" | "htm" => "html",
89 _ => "txt",
90 };
91 return Ok(kind);
92 }
93 if SUB_EXT.contains(&ext.as_str()) {
94 return Ok(subtitle_format(&ext));
95 }
96 Ok("txt") }
98
99fn subtitle_format(ext: &str) -> &'static str {
100 match ext {
101 "vtt" => "vtt",
102 "ass" => "ass",
103 "ssa" => "ssa",
104 _ => "srt",
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use std::io::Write;
112
113 #[test]
114 fn sniff_pdf_and_ole() {
115 let d = std::env::temp_dir();
116 let pdf = d.join("docparser_router_test.pdf");
117 let mut f = std::fs::File::create(&pdf).unwrap();
118 f.write_all(b"%PDF-1.7\n%%EOF").unwrap();
119 drop(f);
120 assert_eq!(sniff(pdf.to_str().unwrap()).unwrap(), "pdf");
121 std::fs::remove_file(&pdf).ok();
122
123 let renamed = d.join("renamed_pdf.doc");
125 let mut f = std::fs::File::create(&renamed).unwrap();
126 f.write_all(b"%PDF-1.7\n%%EOF").unwrap();
127 drop(f);
128 assert_eq!(sniff(renamed.to_str().unwrap()).unwrap(), "pdf");
129 std::fs::remove_file(&renamed).ok();
130
131 let fake = d.join("fake.doc");
133 let mut f = std::fs::File::create(&fake).unwrap();
134 f.write_all(b"NOTANOFFICEFILE").unwrap();
135 drop(f);
136 let err = sniff(fake.to_str().unwrap()).unwrap_err();
137 assert_eq!(err.code.as_str(), "unsupported");
138 std::fs::remove_file(&fake).ok();
139 }
140
141 #[test]
142 fn ooxml_ole_container_is_encrypted() {
143 let d = std::env::temp_dir();
144 let fx = d.join("enc.docx");
145 let mut f = std::fs::File::create(&fx).unwrap();
146 f.write_all(OLE_MAGIC).unwrap();
147 drop(f);
148 let err = sniff(fx.to_str().unwrap()).unwrap_err();
149 assert_eq!(err.code.as_str(), "encrypted");
150 std::fs::remove_file(&fx).ok();
151 }
152
153 #[test]
155 fn subtitle_extensions_route_to_subtitle_formats() {
156 let d = std::env::temp_dir();
157 for (name, want) in [
158 ("r.srt", "srt"),
159 ("r.vtt", "vtt"),
160 ("r.ass", "ass"),
161 ("r.ssa", "ssa"),
162 ] {
163 let p = d.join(format!("docparser_router_{name}"));
164 let mut f = std::fs::File::create(&p).unwrap();
165 f.write_all(b"1\n00:00:01,000 --> 00:00:02,000\nhi\n")
166 .unwrap();
167 drop(f);
168 assert_eq!(sniff(p.to_str().unwrap()).unwrap(), want, "{name}");
169 std::fs::remove_file(&p).ok();
170 }
171 }
172
173 #[test]
175 fn rtf_extension_routes_to_rtf() {
176 let d = std::env::temp_dir();
177 let p = d.join("docparser_router_r.rtf");
178 std::fs::write(&p, br"{\rtf1\ansi hi\par}").unwrap();
179 assert_eq!(sniff(p.to_str().unwrap()).unwrap(), "rtf");
180 std::fs::remove_file(&p).ok();
181 }
182
183 #[test]
185 fn zip_container_routes_odf_family() {
186 let d = std::env::temp_dir();
187 for (name, want) in [
188 ("r.odt", "odt"),
189 ("r.ods", "ods"),
190 ("r.odp", "odp"),
191 ("r.docx", "docx"),
192 ] {
193 let p = d.join(format!("docparser_router_{name}"));
194 let mut f = std::fs::File::create(&p).unwrap();
195 f.write_all(ZIP_MAGIC).unwrap();
196 drop(f);
197 assert_eq!(sniff(p.to_str().unwrap()).unwrap(), want, "{name}");
198 std::fs::remove_file(&p).ok();
199 }
200 }
201}