⎇ 72758e75ce515f355f27615671c95ecaf8c2a8bc
crates/modules/shipote/shipote-discern/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
1//! `shipote-discern` — detección de tipo de contenido sobre buffers.
2//!
3//! Trait + pipeline + discerners default. Devuelve un [`Discernment`] con
4//! `TypeRef` consistente con el broker, confidence, MIME y un `lens` hint
5//! para UIs (reusa el espíritu del `dominant_lens` de nouser).
6
7#![forbid(unsafe_code)]
8
9use brahman_card::TypeRef;
10
11#[derive(Debug, Clone)]
12pub struct Hint<'a> {
13 pub path: Option<&'a str>,
14 pub size_total: Option<u64>,
15}
16
17#[derive(Debug, Clone)]
18pub struct Discernment {
19 pub ty: TypeRef,
20 pub confidence: f32,
21 pub mime: Option<String>,
22 pub lens: Option<String>,
23}
24
25pub trait Discerner: Send + Sync {
26 fn name(&self) -> &str;
27 fn discern(&self, sample: &[u8], hint: &Hint<'_>) -> Option<Discernment>;
28}
29
30pub struct DiscernPipeline {
31 discerners: Vec<Box<dyn Discerner>>,
32}
33
34impl DiscernPipeline {
35 pub fn new() -> Self {
36 Self { discerners: Vec::new() }
37 }
38
39 /// Pipeline con los discerners default. Orden importa: el primer match
40 /// con confidence ≥ `accept_threshold` corta.
41 pub fn default_pipeline() -> Self {
42 let mut p = Self::new();
43 p.push(Box::new(MagicBytes));
44 // CardProbe antes que JsonProbe: una Card es JSON, pero queremos el
45 // TypeRef más específico cuando aplique.
46 p.push(Box::new(CardProbe));
47 p.push(Box::new(JsonProbe));
48 p.push(Box::new(TomlProbe));
49 p.push(Box::new(Utf8Probe));
50 p
51 }
52
53 pub fn push(&mut self, d: Box<dyn Discerner>) {
54 self.discerners.push(d);
55 }
56
57 /// Recorre los discerners y devuelve el primer Discernment con
58 /// confidence ≥ 0.5, o el más confidente si ninguno alcanza el umbral.
59 pub fn discern(&self, sample: &[u8], hint: &Hint<'_>) -> Option<Discernment> {
60 let mut best: Option<Discernment> = None;
61 for d in &self.discerners {
62 if let Some(r) = d.discern(sample, hint) {
63 if r.confidence >= 0.9 {
64 return Some(r);
65 }
66 best = match best {
67 Some(prev) if prev.confidence >= r.confidence => Some(prev),
68 _ => Some(r),
69 };
70 }
71 }
72 best
73 }
74}
75
76impl Default for DiscernPipeline {
77 fn default() -> Self {
78 Self::default_pipeline()
79 }
80}
81
82// =====================================================================
83// Discerners
84// =====================================================================
85
86/// Magic-bytes para formatos comunes. Confidence alta cuando hay match.
87pub struct MagicBytes;
88
89impl Discerner for MagicBytes {
90 fn name(&self) -> &str { "magic-bytes" }
91
92 fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option<Discernment> {
93 let d = |ty: &str, mime: &str, lens: Option<&str>| Discernment {
94 ty: TypeRef::Primitive { name: ty.into() },
95 confidence: 0.99,
96 mime: Some(mime.into()),
97 lens: lens.map(String::from),
98 };
99 match s {
100 x if x.starts_with(&[0x89, b'P', b'N', b'G']) => Some(d("png", "image/png", Some("gallery"))),
101 x if x.starts_with(&[0xFF, 0xD8, 0xFF]) => Some(d("jpeg", "image/jpeg", Some("gallery"))),
102 x if x.starts_with(b"%PDF-") => Some(d("pdf", "application/pdf", Some("reader"))),
103 x if x.starts_with(&[0x7F, b'E', b'L', b'F']) => Some(d("elf", "application/x-executable", None)),
104 x if x.starts_with(&[0x00, 0x61, 0x73, 0x6D]) => Some(d("wasm", "application/wasm", None)),
105 x if x.starts_with(&[0x1F, 0x8B]) => Some(d("gzip", "application/gzip", None)),
106 x if x.starts_with(b"PK\x03\x04") || x.starts_with(b"PK\x05\x06") => {
107 Some(d("zip", "application/zip", None))
108 }
109 x if x.starts_with(b"GIF87a") || x.starts_with(b"GIF89a") => {
110 Some(d("gif", "image/gif", Some("gallery")))
111 }
112 _ => None,
113 }
114 }
115}
116
117/// JSON: parsea el inicio. No requiere parsearlo entero; con que arranque
118/// con `{`/`[` y haga progreso cuenta.
119pub struct JsonProbe;
120
121impl Discerner for JsonProbe {
122 fn name(&self) -> &str { "json" }
123
124 fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option<Discernment> {
125 let trimmed = trim_left(s);
126 let first = *trimmed.first()?;
127 if first != b'{' && first != b'[' {
128 return None;
129 }
130 // Intento parsear tal cual; si falla por truncated, igualmente confidence media.
131 let txt = std::str::from_utf8(trimmed).ok()?;
132 match serde_json::from_str::<serde_json::Value>(txt) {
133 Ok(_) => Some(Discernment {
134 ty: TypeRef::Primitive { name: "json".into() },
135 confidence: 0.95,
136 mime: Some("application/json".into()),
137 lens: Some("tree".into()),
138 }),
139 Err(_) => Some(Discernment {
140 ty: TypeRef::Primitive { name: "json".into() },
141 confidence: 0.6, // sample truncado
142 mime: Some("application/json".into()),
143 lens: Some("tree".into()),
144 }),
145 }
146 }
147}
148
149pub struct TomlProbe;
150
151impl Discerner for TomlProbe {
152 fn name(&self) -> &str { "toml" }
153
154 fn discern(&self, s: &[u8], h: &Hint<'_>) -> Option<Discernment> {
155 let txt = std::str::from_utf8(s).ok()?;
156 // Heurística: presencia de `[seccion]` y/o `clave = valor` y extensión.
157 let looks_like = txt.lines().any(|l| {
158 let l = l.trim();
159 l.starts_with('[') && l.ends_with(']')
160 }) || txt.lines().any(|l| {
161 let l = l.trim();
162 !l.starts_with('#') && l.contains(" = ")
163 });
164 if !looks_like {
165 return None;
166 }
167 let confidence = if h.path.map_or(false, |p| p.ends_with(".toml")) {
168 0.95
169 } else {
170 0.55
171 };
172 // Si parsea, sube confidence.
173 let parsed = toml::from_str::<toml::Value>(txt).is_ok();
174 Some(Discernment {
175 ty: TypeRef::Primitive { name: "toml".into() },
176 confidence: if parsed { 0.93 } else { confidence },
177 mime: Some("application/toml".into()),
178 lens: Some("tree".into()),
179 })
180 }
181}
182
183/// Si el JSON parsea como Card, lo emite como Wit { brahman:card }.
184pub struct CardProbe;
185
186impl Discerner for CardProbe {
187 fn name(&self) -> &str { "card" }
188
189 fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option<Discernment> {
190 let trimmed = trim_left(s);
191 if trimmed.first()? != &b'{' {
192 return None;
193 }
194 let txt = std::str::from_utf8(trimmed).ok()?;
195 let v: serde_json::Value = serde_json::from_str(txt).ok()?;
196 let obj = v.as_object()?;
197 if obj.contains_key("schema_version") && obj.contains_key("id") && obj.contains_key("payload") {
198 Some(Discernment {
199 ty: TypeRef::Wit {
200 package: "brahman:card".into(),
201 interface: None,
202 name: "card".into(),
203 },
204 confidence: 0.97,
205 mime: Some("application/json".into()),
206 lens: Some("card".into()),
207 })
208 } else {
209 None
210 }
211 }
212}
213
214/// Texto UTF-8 plano. Fallback de baja confidence.
215pub struct Utf8Probe;
216
217impl Discerner for Utf8Probe {
218 fn name(&self) -> &str { "utf8" }
219
220 fn discern(&self, s: &[u8], h: &Hint<'_>) -> Option<Discernment> {
221 if s.is_empty() {
222 return None;
223 }
224 let valid = std::str::from_utf8(s).is_ok();
225 if !valid {
226 return None;
227 }
228 // Detectar binario disfrazado: bytes de control fuera de \t\n\r.
229 let suspicious = s.iter().filter(|&&b| b < 0x09 || (b > 0x0D && b < 0x20)).count();
230 if suspicious * 100 / s.len().max(1) > 5 {
231 return None;
232 }
233 let lens = h.path.and_then(|p| {
234 if p.ends_with(".md") { Some("markdown") }
235 else if p.ends_with(".rs") || p.ends_with(".py") || p.ends_with(".go") || p.ends_with(".js") || p.ends_with(".ts") {
236 Some("code")
237 } else { None }
238 }).map(String::from);
239 Some(Discernment {
240 ty: TypeRef::Primitive { name: "text".into() },
241 confidence: 0.5,
242 mime: Some("text/plain; charset=utf-8".into()),
243 lens,
244 })
245 }
246}
247
248fn trim_left(s: &[u8]) -> &[u8] {
249 let mut i = 0;
250 while i < s.len() && (s[i] == b' ' || s[i] == b'\t' || s[i] == b'\n' || s[i] == b'\r') {
251 i += 1;
252 }
253 &s[i..]
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 fn discern(sample: &[u8]) -> Option<Discernment> {
261 DiscernPipeline::default_pipeline().discern(sample, &Hint { path: None, size_total: None })
262 }
263
264 #[test]
265 fn png_detected() {
266 let r = discern(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0]).unwrap();
267 assert_eq!(r.mime.as_deref(), Some("image/png"));
268 assert!(r.confidence > 0.9);
269 }
270
271 #[test]
272 fn json_detected() {
273 let r = discern(b"{\"hello\": 1}").unwrap();
274 assert_eq!(r.mime.as_deref(), Some("application/json"));
275 }
276
277 #[test]
278 fn card_wins_over_plain_json() {
279 let payload = br#"{"schema_version":1,"id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","label":"x","payload":{"Virtual":null},"supervision":"OneShot"}"#;
280 let r = discern(payload).unwrap();
281 match r.ty {
282 TypeRef::Wit { ref package, .. } => assert_eq!(package, "brahman:card"),
283 _ => panic!("expected card"),
284 }
285 }
286
287 #[test]
288 fn utf8_text_fallback() {
289 let r = discern(b"hello world\nthis is text").unwrap();
290 // Puede ser detected as toml (= heurística) o text. Ambos son aceptables, sólo aseguro algo razonable.
291 assert!(r.mime.is_some());
292 }
293
294 #[test]
295 fn binary_rejected_by_utf8() {
296 let mut bytes = vec![0u8; 100];
297 bytes[0] = 0x00;
298 bytes[1] = 0x01;
299 bytes[2] = 0x02;
300 let r = DiscernPipeline::default_pipeline().discern(&bytes, &Hint { path: None, size_total: None });
301 // Tras Utf8Probe rechazar, no hay match → None.
302 // Si por casualidad otro discerner mata antes, también es OK.
303 if let Some(r) = r {
304 assert_ne!(r.mime.as_deref(), Some("text/plain; charset=utf-8"));
305 }
306 }
307}