/home/noah/src/realizar/src/format.rs
Line | Count | Source |
1 | | //! Unified Model Format Detection and Loading |
2 | | //! |
3 | | //! Per spec §3: Format Support Matrix - auto-detect APR, GGUF, SafeTensors from magic bytes. |
4 | | //! |
5 | | //! ## Jidoka (Built-in Quality) |
6 | | //! |
7 | | //! - CRC32 verification for APR format |
8 | | //! - Header size validation for SafeTensors (DOS protection) |
9 | | //! - Magic byte validation for GGUF |
10 | | //! |
11 | | //! ## Supported Formats |
12 | | //! |
13 | | //! | Format | Magic | Extension | |
14 | | //! |--------|-------|-----------| |
15 | | //! | APR | `APR\0` | `.apr` | |
16 | | //! | GGUF | `GGUF` | `.gguf` | |
17 | | //! | SafeTensors | (u64 header size) | `.safetensors` | |
18 | | |
19 | | use std::path::Path; |
20 | | |
21 | | /// Detected model format |
22 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
23 | | pub enum ModelFormat { |
24 | | /// Aprender native format (first-class support) |
25 | | Apr, |
26 | | /// GGUF format (llama.cpp compatible) |
27 | | Gguf, |
28 | | /// SafeTensors format (HuggingFace compatible) |
29 | | SafeTensors, |
30 | | } |
31 | | |
32 | | impl std::fmt::Display for ModelFormat { |
33 | 10 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
34 | 10 | match self { |
35 | 2 | Self::Apr => write!(f, "APR"), |
36 | 6 | Self::Gguf => write!(f, "GGUF"), |
37 | 2 | Self::SafeTensors => write!(f, "SafeTensors"), |
38 | | } |
39 | 10 | } |
40 | | } |
41 | | |
42 | | /// Errors during format detection |
43 | | #[derive(Debug, Clone, PartialEq, Eq)] |
44 | | pub enum FormatError { |
45 | | /// Data too short for format detection (need at least 8 bytes) |
46 | | TooShort { |
47 | | /// Actual length |
48 | | len: usize, |
49 | | }, |
50 | | /// Unknown format (no magic bytes matched) |
51 | | UnknownFormat, |
52 | | /// SafeTensors header too large (DOS protection per spec §7.1) |
53 | | HeaderTooLarge { |
54 | | /// Header size in bytes |
55 | | size: u64, |
56 | | }, |
57 | | /// File extension doesn't match detected format |
58 | | ExtensionMismatch { |
59 | | /// Detected format |
60 | | detected: ModelFormat, |
61 | | /// Extension from filename |
62 | | extension: String, |
63 | | }, |
64 | | } |
65 | | |
66 | | impl std::fmt::Display for FormatError { |
67 | 13 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
68 | 13 | match self { |
69 | 5 | Self::TooShort { len } => { |
70 | 5 | write!( |
71 | 5 | f, |
72 | 5 | "Data too short for format detection: {len} bytes (need 8)" |
73 | | ) |
74 | | }, |
75 | 5 | Self::UnknownFormat => write!(f, "Unknown model format (no magic bytes matched)"), |
76 | 1 | Self::HeaderTooLarge { size } => write!( |
77 | 1 | f, |
78 | 1 | "SafeTensors header too large: {size} bytes (max 100MB for DOS protection)" |
79 | | ), |
80 | | Self::ExtensionMismatch { |
81 | 2 | detected, |
82 | 2 | extension, |
83 | | } => { |
84 | 2 | write!( |
85 | 2 | f, |
86 | 2 | "Extension mismatch: detected {detected} but file has extension .{extension}" |
87 | | ) |
88 | | }, |
89 | | } |
90 | 13 | } |
91 | | } |
92 | | |
93 | | impl std::error::Error for FormatError {} |
94 | | |
95 | | /// APR format magic bytes (first 3 bytes, 4th is version) |
96 | | /// |
97 | | /// APR v1: `APR1` (0x41505231) |
98 | | /// APR v2: `APR2` (0x41505232) |
99 | | /// Legacy: `APR\0` (0x41505200) |
100 | | pub const APR_MAGIC: &[u8; 3] = b"APR"; |
101 | | |
102 | | /// GGUF format magic bytes |
103 | | pub const GGUF_MAGIC: &[u8; 4] = b"GGUF"; |
104 | | |
105 | | /// Maximum SafeTensors header size (100MB for DOS protection per spec §7.1) |
106 | | pub const MAX_SAFETENSORS_HEADER: u64 = 100_000_000; |
107 | | |
108 | | /// Detect model format from magic bytes (Jidoka: fail-fast) |
109 | | /// |
110 | | /// Per spec §3.2: Format Detection |
111 | | /// |
112 | | /// # Arguments |
113 | | /// |
114 | | /// * `data` - First 8+ bytes of the model file |
115 | | /// |
116 | | /// # Returns |
117 | | /// |
118 | | /// Detected format or error |
119 | | /// |
120 | | /// # Errors |
121 | | /// |
122 | | /// Returns error if: |
123 | | /// - Data is too short (<8 bytes) |
124 | | /// - No known magic bytes detected |
125 | | /// - SafeTensors header size exceeds limit (DOS protection) |
126 | | /// |
127 | | /// # Example |
128 | | /// |
129 | | /// ``` |
130 | | /// use realizar::format::{detect_format, ModelFormat}; |
131 | | /// |
132 | | /// // APR format |
133 | | /// let apr_data = b"APR\0xxxxxxxxxxxx"; |
134 | | /// assert_eq!(detect_format(apr_data).expect("test"), ModelFormat::Apr); |
135 | | /// |
136 | | /// // GGUF format |
137 | | /// let gguf_data = b"GGUFxxxxxxxxxxxx"; |
138 | | /// assert_eq!(detect_format(gguf_data).expect("test"), ModelFormat::Gguf); |
139 | | /// ``` |
140 | 61 | pub fn detect_format(data: &[u8]) -> Result<ModelFormat, FormatError> { |
141 | 61 | if data.len() < 8 { |
142 | 6 | return Err(FormatError::TooShort { len: data.len() }); |
143 | 55 | } |
144 | | |
145 | | // Check APR magic - first 3 bytes are "APR", 4th byte is version (1, 2, or 0 for legacy) |
146 | 55 | if &data[0..3] == APR_MAGIC { |
147 | 34 | let version = data[3]; |
148 | | // Accept version 1, 2, or legacy (0) |
149 | 34 | if version == b'1' || version == b'2'33 || version == 032 { |
150 | 33 | return Ok(ModelFormat::Apr); |
151 | 1 | } |
152 | 21 | } |
153 | | |
154 | | // Check GGUF magic |
155 | 22 | if &data[0..4] == GGUF_MAGIC { |
156 | 9 | return Ok(ModelFormat::Gguf); |
157 | 13 | } |
158 | | |
159 | | // SafeTensors: first 8 bytes are header size (little-endian u64) |
160 | | // If it's a reasonable size, assume SafeTensors |
161 | 13 | let header_size = u64::from_le_bytes(data[0..8].try_into().expect("slice is exactly 8 bytes")); |
162 | | |
163 | | // SafeTensors header should be reasonable size |
164 | | // Very large values indicate this isn't SafeTensors |
165 | 13 | if header_size < MAX_SAFETENSORS_HEADER && header_size > 011 { |
166 | 7 | return Ok(ModelFormat::SafeTensors); |
167 | 6 | } |
168 | | |
169 | | // Check if header size looks like SafeTensors but is too large |
170 | 6 | if header_size >= MAX_SAFETENSORS_HEADER { |
171 | 2 | return Err(FormatError::HeaderTooLarge { size: header_size }); |
172 | 4 | } |
173 | | |
174 | 4 | Err(FormatError::UnknownFormat) |
175 | 61 | } |
176 | | |
177 | | /// Detect format from file path (using extension as hint, then verify magic) |
178 | | /// |
179 | | /// # Arguments |
180 | | /// |
181 | | /// * `path` - Path to model file |
182 | | /// |
183 | | /// # Returns |
184 | | /// |
185 | | /// Detected format (verified against magic bytes if data provided) |
186 | | /// |
187 | | /// # Errors |
188 | | /// |
189 | | /// Returns `FormatError::UnknownFormat` if extension is not recognized. |
190 | | /// |
191 | | /// # Example |
192 | | /// |
193 | | /// ``` |
194 | | /// use realizar::format::{detect_format_from_path, ModelFormat}; |
195 | | /// use std::path::Path; |
196 | | /// |
197 | | /// assert_eq!( |
198 | | /// detect_format_from_path(Path::new("model.apr")).expect("test"), |
199 | | /// ModelFormat::Apr |
200 | | /// ); |
201 | | /// ``` |
202 | 8 | pub fn detect_format_from_path(path: &Path) -> Result<ModelFormat, FormatError> { |
203 | 8 | let extension = path.extension().and_then(|e| e.to_str()).unwrap_or(""); |
204 | | |
205 | 8 | match extension.to_lowercase().as_str() { |
206 | 8 | "apr" => Ok(ModelFormat::Apr)4 , |
207 | 4 | "gguf" => Ok(ModelFormat::Gguf)1 , |
208 | 3 | "safetensors" => Ok(ModelFormat::SafeTensors)1 , |
209 | 2 | _ => Err(FormatError::UnknownFormat), |
210 | | } |
211 | 8 | } |
212 | | |
213 | | /// Detect format from path and verify against data magic bytes |
214 | | /// |
215 | | /// Per Jidoka: stop immediately if extension doesn't match magic |
216 | | /// |
217 | | /// # Arguments |
218 | | /// |
219 | | /// * `path` - Path to model file |
220 | | /// * `data` - First 8+ bytes of model data |
221 | | /// |
222 | | /// # Returns |
223 | | /// |
224 | | /// Verified format or error if mismatch |
225 | | /// |
226 | | /// # Errors |
227 | | /// |
228 | | /// Returns error if: |
229 | | /// - Format cannot be detected from magic bytes |
230 | | /// - File extension doesn't match detected format |
231 | 3 | pub fn detect_and_verify_format(path: &Path, data: &[u8]) -> Result<ModelFormat, FormatError> { |
232 | 3 | let from_data = detect_format(data)?0 ; |
233 | 3 | let from_path = detect_format_from_path(path); |
234 | | |
235 | | // If path detection succeeded, verify it matches data |
236 | 3 | if let Ok(path_format2 ) = from_path { |
237 | 2 | if path_format != from_data { |
238 | | return Err(FormatError::ExtensionMismatch { |
239 | 1 | detected: from_data, |
240 | 1 | extension: path |
241 | 1 | .extension() |
242 | 1 | .and_then(|e| e.to_str()) |
243 | 1 | .unwrap_or("unknown") |
244 | 1 | .to_string(), |
245 | | }); |
246 | 1 | } |
247 | 1 | } |
248 | | |
249 | | // Data-based detection is authoritative |
250 | 2 | Ok(from_data) |
251 | 3 | } |
252 | | |
253 | | #[cfg(test)] |
254 | | mod tests { |
255 | | use super::*; |
256 | | |
257 | | // ===== EXTREME TDD: Format Detection Tests ===== |
258 | | |
259 | | #[test] |
260 | 1 | fn test_detect_apr_format_legacy() { |
261 | 1 | let data = b"APR\0xxxxxxxxxxxxxxxx"; |
262 | 1 | assert_eq!(detect_format(data).expect("test"), ModelFormat::Apr); |
263 | 1 | } |
264 | | |
265 | | #[test] |
266 | 1 | fn test_detect_apr_format_v1() { |
267 | 1 | let data = b"APR1xxxxxxxxxxxxxxxx"; |
268 | 1 | assert_eq!(detect_format(data).expect("test"), ModelFormat::Apr); |
269 | 1 | } |
270 | | |
271 | | #[test] |
272 | 1 | fn test_detect_apr_format_v2() { |
273 | 1 | let data = b"APR2xxxxxxxxxxxxxxxx"; |
274 | 1 | assert_eq!(detect_format(data).expect("test"), ModelFormat::Apr); |
275 | 1 | } |
276 | | |
277 | | #[test] |
278 | 1 | fn test_detect_gguf_format() { |
279 | 1 | let data = b"GGUFxxxxxxxxxxxxxxxx"; |
280 | 1 | assert_eq!(detect_format(data).expect("test"), ModelFormat::Gguf); |
281 | 1 | } |
282 | | |
283 | | #[test] |
284 | 1 | fn test_detect_safetensors_format() { |
285 | | // SafeTensors: first 8 bytes are header size (little-endian) |
286 | | // A reasonable header size like 1000 bytes |
287 | 1 | let mut data = vec![0u8; 16]; |
288 | 1 | let header_size: u64 = 1000; |
289 | 1 | data[0..8].copy_from_slice(&header_size.to_le_bytes()); |
290 | 1 | assert_eq!( |
291 | 1 | detect_format(&data).expect("test"), |
292 | | ModelFormat::SafeTensors |
293 | | ); |
294 | 1 | } |
295 | | |
296 | | #[test] |
297 | 1 | fn test_detect_format_too_short() { |
298 | 1 | let data = b"APR"; // Only 3 bytes |
299 | 1 | let result = detect_format(data); |
300 | 1 | assert!(matches!0 (result, Err(FormatError::TooShort { len: 3 }))); |
301 | 1 | } |
302 | | |
303 | | #[test] |
304 | 1 | fn test_detect_format_empty() { |
305 | 1 | let data: &[u8] = &[]; |
306 | 1 | let result = detect_format(data); |
307 | 1 | assert!(matches!0 (result, Err(FormatError::TooShort { len: 0 }))); |
308 | 1 | } |
309 | | |
310 | | #[test] |
311 | 1 | fn test_detect_safetensors_header_too_large() { |
312 | | // Header size > 100MB should fail (DOS protection) |
313 | 1 | let mut data = vec![0u8; 16]; |
314 | 1 | let header_size: u64 = 200_000_000; // 200MB |
315 | 1 | data[0..8].copy_from_slice(&header_size.to_le_bytes()); |
316 | 1 | let result = detect_format(&data); |
317 | 1 | assert!(matches!0 ( |
318 | 1 | result, |
319 | | Err(FormatError::HeaderTooLarge { size: 200_000_000 }) |
320 | | )); |
321 | 1 | } |
322 | | |
323 | | #[test] |
324 | 1 | fn test_detect_unknown_format() { |
325 | | // Random bytes that don't match any format |
326 | | // Zero header size means not SafeTensors either |
327 | 1 | let data = b"\x00\x00\x00\x00\x00\x00\x00\x00xxxx"; |
328 | 1 | let result = detect_format(data); |
329 | 1 | assert!(matches!0 (result, Err(FormatError::UnknownFormat))); |
330 | 1 | } |
331 | | |
332 | | #[test] |
333 | 1 | fn test_detect_format_from_path_apr() { |
334 | 1 | let path = Path::new("model.apr"); |
335 | 1 | assert_eq!( |
336 | 1 | detect_format_from_path(path).expect("test"), |
337 | | ModelFormat::Apr |
338 | | ); |
339 | 1 | } |
340 | | |
341 | | #[test] |
342 | 1 | fn test_detect_format_from_path_gguf() { |
343 | 1 | let path = Path::new("llama-7b-q4.gguf"); |
344 | 1 | assert_eq!( |
345 | 1 | detect_format_from_path(path).expect("test"), |
346 | | ModelFormat::Gguf |
347 | | ); |
348 | 1 | } |
349 | | |
350 | | #[test] |
351 | 1 | fn test_detect_format_from_path_safetensors() { |
352 | 1 | let path = Path::new("model.safetensors"); |
353 | 1 | assert_eq!( |
354 | 1 | detect_format_from_path(path).expect("test"), |
355 | | ModelFormat::SafeTensors |
356 | | ); |
357 | 1 | } |
358 | | |
359 | | #[test] |
360 | 1 | fn test_detect_format_from_path_unknown() { |
361 | 1 | let path = Path::new("model.bin"); |
362 | 1 | let result = detect_format_from_path(path); |
363 | 1 | assert!(matches!0 (result, Err(FormatError::UnknownFormat))); |
364 | 1 | } |
365 | | |
366 | | #[test] |
367 | 1 | fn test_detect_format_from_path_uppercase() { |
368 | | // Extension comparison should be case-insensitive |
369 | 1 | let path = Path::new("MODEL.APR"); |
370 | 1 | assert_eq!( |
371 | 1 | detect_format_from_path(path).expect("test"), |
372 | | ModelFormat::Apr |
373 | | ); |
374 | 1 | } |
375 | | |
376 | | #[test] |
377 | 1 | fn test_detect_and_verify_format_match() { |
378 | 1 | let path = Path::new("model.apr"); |
379 | 1 | let data = b"APR\0xxxxxxxxxxxxxxxx"; |
380 | 1 | assert_eq!( |
381 | 1 | detect_and_verify_format(path, data).expect("test"), |
382 | | ModelFormat::Apr |
383 | | ); |
384 | 1 | } |
385 | | |
386 | | #[test] |
387 | 1 | fn test_detect_and_verify_format_mismatch() { |
388 | 1 | let path = Path::new("model.apr"); // Says APR |
389 | 1 | let data = b"GGUFxxxxxxxxxxxxxxxx"; // But data is GGUF |
390 | 1 | let result = detect_and_verify_format(path, data); |
391 | 1 | assert!(matches!0 ( |
392 | 1 | result, |
393 | | Err(FormatError::ExtensionMismatch { |
394 | | detected: ModelFormat::Gguf, |
395 | | .. |
396 | | }) |
397 | | )); |
398 | 1 | } |
399 | | |
400 | | #[test] |
401 | 1 | fn test_detect_and_verify_unknown_extension_ok() { |
402 | | // Unknown extension but valid magic should work |
403 | 1 | let path = Path::new("model.bin"); |
404 | 1 | let data = b"APR\0xxxxxxxxxxxxxxxx"; |
405 | 1 | assert_eq!( |
406 | 1 | detect_and_verify_format(path, data).expect("test"), |
407 | | ModelFormat::Apr |
408 | | ); |
409 | 1 | } |
410 | | |
411 | | #[test] |
412 | 1 | fn test_model_format_display() { |
413 | 1 | assert_eq!(format!("{}", ModelFormat::Apr), "APR"); |
414 | 1 | assert_eq!(format!("{}", ModelFormat::Gguf), "GGUF"); |
415 | 1 | assert_eq!(format!("{}", ModelFormat::SafeTensors), "SafeTensors"); |
416 | 1 | } |
417 | | |
418 | | #[test] |
419 | 1 | fn test_format_error_display() { |
420 | 1 | let err = FormatError::TooShort { len: 5 }; |
421 | 1 | assert!(err.to_string().contains("5 bytes")); |
422 | | |
423 | 1 | let err = FormatError::UnknownFormat; |
424 | 1 | assert!(err.to_string().contains("Unknown")); |
425 | | |
426 | 1 | let err = FormatError::HeaderTooLarge { size: 999 }; |
427 | 1 | assert!(err.to_string().contains("999 bytes")); |
428 | | |
429 | 1 | let err = FormatError::ExtensionMismatch { |
430 | 1 | detected: ModelFormat::Gguf, |
431 | 1 | extension: "apr".to_string(), |
432 | 1 | }; |
433 | 1 | assert!(err.to_string().contains("GGUF")); |
434 | 1 | assert!(err.to_string().contains(".apr")); |
435 | 1 | } |
436 | | |
437 | | #[test] |
438 | 1 | fn test_magic_constants() { |
439 | | // APR_MAGIC is now 3 bytes, version in 4th byte |
440 | 1 | assert_eq!(APR_MAGIC, b"APR"); |
441 | 1 | assert_eq!(GGUF_MAGIC, b"GGUF"); |
442 | 1 | assert_eq!(MAX_SAFETENSORS_HEADER, 100_000_000); |
443 | 1 | } |
444 | | |
445 | | // ===== Property-based edge cases ===== |
446 | | |
447 | | #[test] |
448 | 1 | fn test_exactly_8_bytes_safetensors() { |
449 | | // Exactly 8 bytes with valid header size |
450 | 1 | let header_size: u64 = 500; |
451 | 1 | let data = header_size.to_le_bytes(); |
452 | 1 | assert_eq!( |
453 | 1 | detect_format(&data).expect("test"), |
454 | | ModelFormat::SafeTensors |
455 | | ); |
456 | 1 | } |
457 | | |
458 | | #[test] |
459 | 1 | fn test_apr_with_trailing_data() { |
460 | | // APR magic followed by lots of other data |
461 | 1 | let mut data = b"APR\0".to_vec(); |
462 | 1 | data.extend_from_slice(&[0u8; 1000]); |
463 | 1 | assert_eq!(detect_format(&data).expect("test"), ModelFormat::Apr); |
464 | 1 | } |
465 | | |
466 | | #[test] |
467 | 1 | fn test_gguf_with_trailing_data() { |
468 | | // GGUF magic followed by lots of other data |
469 | 1 | let mut data = b"GGUF".to_vec(); |
470 | 1 | data.extend_from_slice(&[0u8; 1000]); |
471 | 1 | assert_eq!(detect_format(&data).expect("test"), ModelFormat::Gguf); |
472 | 1 | } |
473 | | |
474 | | #[test] |
475 | 1 | fn test_safetensors_boundary_header_size() { |
476 | | // Just under the limit |
477 | 1 | let mut data = vec![0u8; 16]; |
478 | 1 | let header_size: u64 = MAX_SAFETENSORS_HEADER - 1; |
479 | 1 | data[0..8].copy_from_slice(&header_size.to_le_bytes()); |
480 | 1 | assert_eq!( |
481 | 1 | detect_format(&data).expect("test"), |
482 | | ModelFormat::SafeTensors |
483 | | ); |
484 | 1 | } |
485 | | |
486 | | #[test] |
487 | 1 | fn test_safetensors_exactly_at_limit() { |
488 | | // Exactly at limit should fail |
489 | 1 | let mut data = vec![0u8; 16]; |
490 | 1 | let header_size: u64 = MAX_SAFETENSORS_HEADER; |
491 | 1 | data[0..8].copy_from_slice(&header_size.to_le_bytes()); |
492 | 1 | let result = detect_format(&data); |
493 | 1 | assert!(matches!0 (result, Err(FormatError::HeaderTooLarge { .. }))); |
494 | 1 | } |
495 | | } |