1#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum NativeArtifactFrameError {
23 TooShort {
24 artifact: &'static str,
25 },
26 InvalidMagic {
27 artifact: &'static str,
28 },
29 Truncated {
30 artifact: &'static str,
31 offset: usize,
32 reason: &'static str,
33 },
34 InvalidUtf8 {
35 offset: usize,
36 },
37 EntryCountMismatch {
38 expected: u64,
39 seen: u64,
40 },
41}
42
43impl std::fmt::Display for NativeArtifactFrameError {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 match self {
46 Self::TooShort { artifact } => write!(f, "invalid {artifact} artifact"),
47 Self::InvalidMagic { artifact } => write!(f, "invalid {artifact} artifact"),
48 Self::Truncated {
49 artifact,
50 offset,
51 reason,
52 } => write!(
53 f,
54 "truncated {artifact} artifact at offset {offset}: {reason}"
55 ),
56 Self::InvalidUtf8 { offset } => {
57 write!(f, "invalid utf-8 in native artifact at offset {offset}")
58 }
59 Self::EntryCountMismatch { expected, seen } => write!(
60 f,
61 "document path/value artifact entry count mismatch: expected {expected}, saw {seen}"
62 ),
63 }
64 }
65}
66
67impl std::error::Error for NativeArtifactFrameError {}
68
69fn push_string(buf: &mut Vec<u8>, value: &str) {
70 let bytes = value.as_bytes();
71 buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
72 buf.extend_from_slice(bytes);
73}
74
75fn read_string(
76 bytes: &[u8],
77 pos: &mut usize,
78 artifact: &'static str,
79) -> Result<String, NativeArtifactFrameError> {
80 let len = read_u32(bytes, pos, artifact, "string length")?;
81 let len = usize::try_from(len).map_err(|_| NativeArtifactFrameError::Truncated {
82 artifact,
83 offset: *pos,
84 reason: "string length",
85 })?;
86 let end = (*pos)
87 .checked_add(len)
88 .ok_or(NativeArtifactFrameError::Truncated {
89 artifact,
90 offset: *pos,
91 reason: "string content",
92 })?;
93 if end > bytes.len() {
94 return Err(NativeArtifactFrameError::Truncated {
95 artifact,
96 offset: *pos,
97 reason: "string content",
98 });
99 }
100 let value = std::str::from_utf8(&bytes[*pos..end])
101 .map_err(|_| NativeArtifactFrameError::InvalidUtf8 { offset: *pos })?
102 .to_string();
103 *pos = end;
104 Ok(value)
105}
106
107fn read_u32(
108 bytes: &[u8],
109 pos: &mut usize,
110 artifact: &'static str,
111 reason: &'static str,
112) -> Result<u32, NativeArtifactFrameError> {
113 if *pos + 4 > bytes.len() {
114 return Err(NativeArtifactFrameError::Truncated {
115 artifact,
116 offset: *pos,
117 reason,
118 });
119 }
120 let value = u32::from_le_bytes(
121 bytes[*pos..*pos + 4]
122 .try_into()
123 .expect("u32 length checked"),
124 );
125 *pos += 4;
126 Ok(value)
127}
128
129fn read_u64(
130 bytes: &[u8],
131 pos: &mut usize,
132 artifact: &'static str,
133 reason: &'static str,
134) -> Result<u64, NativeArtifactFrameError> {
135 if *pos + 8 > bytes.len() {
136 return Err(NativeArtifactFrameError::Truncated {
137 artifact,
138 offset: *pos,
139 reason,
140 });
141 }
142 let value = u64::from_le_bytes(
143 bytes[*pos..*pos + 8]
144 .try_into()
145 .expect("u64 length checked"),
146 );
147 *pos += 8;
148 Ok(value)
149}
150
151fn read_f32(
152 bytes: &[u8],
153 pos: &mut usize,
154 artifact: &'static str,
155 reason: &'static str,
156) -> Result<f32, NativeArtifactFrameError> {
157 if *pos + 4 > bytes.len() {
158 return Err(NativeArtifactFrameError::Truncated {
159 artifact,
160 offset: *pos,
161 reason,
162 });
163 }
164 let value = f32::from_le_bytes(
165 bytes[*pos..*pos + 4]
166 .try_into()
167 .expect("f32 length checked"),
168 );
169 *pos += 4;
170 Ok(value)
171}
172
173pub const NATIVE_GRAPH_ADJACENCY_MAGIC: [u8; 4] = *b"RDGA";
179
180#[derive(Debug, Clone, PartialEq)]
182pub struct NativeGraphEdge {
183 pub edge_id: u64,
185 pub from_node: String,
186 pub to_node: String,
187 pub label: String,
188 pub weight: f32,
189}
190
191#[derive(Debug, Clone, PartialEq)]
193pub struct NativeGraphAdjacencyFrame {
194 pub edges: Vec<NativeGraphEdge>,
195}
196
197pub fn encode_native_graph_adjacency_frame(frame: &NativeGraphAdjacencyFrame) -> Vec<u8> {
199 let mut data = Vec::with_capacity(32 + frame.edges.len() * 48);
200 data.extend_from_slice(&NATIVE_GRAPH_ADJACENCY_MAGIC);
201 data.extend_from_slice(&(frame.edges.len() as u32).to_le_bytes());
202 for edge in &frame.edges {
203 data.extend_from_slice(&edge.edge_id.to_le_bytes());
204 push_string(&mut data, &edge.from_node);
205 push_string(&mut data, &edge.to_node);
206 push_string(&mut data, &edge.label);
207 data.extend_from_slice(&edge.weight.to_le_bytes());
208 }
209 data
210}
211
212pub fn decode_native_graph_adjacency_frame(
214 bytes: &[u8],
215) -> Result<NativeGraphAdjacencyFrame, NativeArtifactFrameError> {
216 const ARTIFACT: &str = "graph adjacency";
217 if bytes.len() < 8 || bytes[0..4] != NATIVE_GRAPH_ADJACENCY_MAGIC {
218 return Err(NativeArtifactFrameError::InvalidMagic { artifact: ARTIFACT });
219 }
220 let mut pos = 4usize;
221 let edge_count = read_u32(bytes, &mut pos, ARTIFACT, "edge count")?;
222 let mut edges = Vec::new();
223 for _ in 0..edge_count {
224 let edge_id = read_u64(bytes, &mut pos, ARTIFACT, "edge id")?;
225 let from_node = read_string(bytes, &mut pos, ARTIFACT)?;
226 let to_node = read_string(bytes, &mut pos, ARTIFACT)?;
227 let label = read_string(bytes, &mut pos, ARTIFACT)?;
228 let weight = read_f32(bytes, &mut pos, ARTIFACT, "edge weight")?;
229 edges.push(NativeGraphEdge {
230 edge_id,
231 from_node,
232 to_node,
233 label,
234 weight,
235 });
236 }
237 Ok(NativeGraphAdjacencyFrame { edges })
238}
239
240pub const NATIVE_FULLTEXT_MAGIC: [u8; 4] = *b"RDFT";
246
247#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct NativeFulltextPosting {
250 pub entity_id: u64,
252 pub term_count: u32,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct NativeFulltextTerm {
258 pub term: String,
259 pub postings: Vec<NativeFulltextPosting>,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq)]
264pub struct NativeFulltextFrame {
265 pub collection: String,
266 pub doc_count: u32,
268 pub terms: Vec<NativeFulltextTerm>,
269}
270
271pub fn encode_native_fulltext_frame(frame: &NativeFulltextFrame) -> Vec<u8> {
273 let mut data = Vec::with_capacity(64 + frame.terms.len() * 32);
274 data.extend_from_slice(&NATIVE_FULLTEXT_MAGIC);
275 push_string(&mut data, &frame.collection);
276 data.extend_from_slice(&frame.doc_count.to_le_bytes());
277 data.extend_from_slice(&(frame.terms.len() as u32).to_le_bytes());
278 for term in &frame.terms {
279 push_string(&mut data, &term.term);
280 data.extend_from_slice(&(term.postings.len() as u32).to_le_bytes());
281 for posting in &term.postings {
282 data.extend_from_slice(&posting.entity_id.to_le_bytes());
283 data.extend_from_slice(&posting.term_count.to_le_bytes());
284 }
285 }
286 data
287}
288
289pub fn decode_native_fulltext_frame(
291 bytes: &[u8],
292) -> Result<NativeFulltextFrame, NativeArtifactFrameError> {
293 const ARTIFACT: &str = "fulltext";
294 if bytes.len() < 12 || bytes[0..4] != NATIVE_FULLTEXT_MAGIC {
295 return Err(NativeArtifactFrameError::InvalidMagic { artifact: ARTIFACT });
296 }
297 let mut pos = 4usize;
298 let collection = read_string(bytes, &mut pos, ARTIFACT)?;
299 let doc_count = read_u32(bytes, &mut pos, ARTIFACT, "doc count")?;
300 let term_count = read_u32(bytes, &mut pos, ARTIFACT, "term count")?;
301 let mut terms = Vec::new();
302 for _ in 0..term_count {
303 let term = read_string(bytes, &mut pos, ARTIFACT)?;
304 let entry_count = read_u32(bytes, &mut pos, ARTIFACT, "posting count")?;
305 let mut postings = Vec::new();
306 for _ in 0..entry_count {
307 let entity_id = read_u64(bytes, &mut pos, ARTIFACT, "posting entity id")?;
308 let term_count = read_u32(bytes, &mut pos, ARTIFACT, "posting term count")?;
309 postings.push(NativeFulltextPosting {
310 entity_id,
311 term_count,
312 });
313 }
314 terms.push(NativeFulltextTerm { term, postings });
315 }
316 Ok(NativeFulltextFrame {
317 collection,
318 doc_count,
319 terms,
320 })
321}
322
323pub const NATIVE_DOC_PATHVALUE_MAGIC: [u8; 4] = *b"RDDP";
329
330#[derive(Debug, Clone, PartialEq, Eq)]
332pub struct NativeDocPathValueEntry {
333 pub path: String,
334 pub value: String,
335}
336
337#[derive(Debug, Clone, PartialEq, Eq)]
339pub struct NativeDocPathValue {
340 pub entity_id: u64,
342 pub entries: Vec<NativeDocPathValueEntry>,
343}
344
345#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct NativeDocPathValueFrame {
348 pub collection: String,
349 pub docs: Vec<NativeDocPathValue>,
350}
351
352impl NativeDocPathValueFrame {
353 fn total_entries(&self) -> usize {
356 self.docs.iter().map(|doc| doc.entries.len()).sum()
357 }
358}
359
360pub fn encode_native_doc_pathvalue_frame(frame: &NativeDocPathValueFrame) -> Vec<u8> {
362 let total_entries = frame.total_entries();
363 let mut data = Vec::with_capacity(64 + total_entries * 48);
364 data.extend_from_slice(&NATIVE_DOC_PATHVALUE_MAGIC);
365 push_string(&mut data, &frame.collection);
366 data.extend_from_slice(&(frame.docs.len() as u32).to_le_bytes());
367 data.extend_from_slice(&(total_entries as u32).to_le_bytes());
368 for doc in &frame.docs {
369 data.extend_from_slice(&doc.entity_id.to_le_bytes());
370 data.extend_from_slice(&(doc.entries.len() as u32).to_le_bytes());
371 for entry in &doc.entries {
372 push_string(&mut data, &entry.path);
373 push_string(&mut data, &entry.value);
374 }
375 }
376 data
377}
378
379pub fn decode_native_doc_pathvalue_frame(
382 bytes: &[u8],
383) -> Result<NativeDocPathValueFrame, NativeArtifactFrameError> {
384 const ARTIFACT: &str = "document path/value";
385 if bytes.len() < 12 || bytes[0..4] != NATIVE_DOC_PATHVALUE_MAGIC {
386 return Err(NativeArtifactFrameError::InvalidMagic { artifact: ARTIFACT });
387 }
388 let mut pos = 4usize;
389 let collection = read_string(bytes, &mut pos, ARTIFACT)?;
390 let doc_count = read_u32(bytes, &mut pos, ARTIFACT, "doc count")?;
391 let total_entries = read_u32(bytes, &mut pos, ARTIFACT, "total entries")? as u64;
392 let mut docs = Vec::new();
393 let mut seen_entries = 0u64;
394 for _ in 0..doc_count {
395 let entity_id = read_u64(bytes, &mut pos, ARTIFACT, "document entity id")?;
396 let entry_count = read_u32(bytes, &mut pos, ARTIFACT, "document entry count")?;
397 let mut entries = Vec::new();
398 for _ in 0..entry_count {
399 let path = read_string(bytes, &mut pos, ARTIFACT)?;
400 let value = read_string(bytes, &mut pos, ARTIFACT)?;
401 entries.push(NativeDocPathValueEntry { path, value });
402 seen_entries += 1;
403 }
404 docs.push(NativeDocPathValue { entity_id, entries });
405 }
406 if seen_entries != total_entries {
407 return Err(NativeArtifactFrameError::EntryCountMismatch {
408 expected: total_entries,
409 seen: seen_entries,
410 });
411 }
412 Ok(NativeDocPathValueFrame { collection, docs })
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn rdga_round_trips_and_pins_layout() {
421 let frame = NativeGraphAdjacencyFrame {
422 edges: vec![
423 NativeGraphEdge {
424 edge_id: 42,
425 from_node: "a".into(),
426 to_node: "b".into(),
427 label: "knows".into(),
428 weight: 1.5,
429 },
430 NativeGraphEdge {
431 edge_id: 7,
432 from_node: "b".into(),
433 to_node: "c".into(),
434 label: "likes".into(),
435 weight: -0.25,
436 },
437 ],
438 };
439 let encoded = encode_native_graph_adjacency_frame(&frame);
440 assert_eq!(&encoded[0..4], b"RDGA");
441 assert_eq!(&encoded[4..8], &2u32.to_le_bytes());
442 assert_eq!(&encoded[8..16], &42u64.to_le_bytes());
444 let decoded = decode_native_graph_adjacency_frame(&encoded).unwrap();
445 assert_eq!(decoded, frame);
446 assert_eq!(encode_native_graph_adjacency_frame(&decoded), encoded);
447 }
448
449 #[test]
450 fn rdft_round_trips_and_pins_layout() {
451 let frame = NativeFulltextFrame {
452 collection: "docs".into(),
453 doc_count: 3,
454 terms: vec![
455 NativeFulltextTerm {
456 term: "alpha".into(),
457 postings: vec![
458 NativeFulltextPosting {
459 entity_id: 100,
460 term_count: 2,
461 },
462 NativeFulltextPosting {
463 entity_id: 101,
464 term_count: 1,
465 },
466 ],
467 },
468 NativeFulltextTerm {
469 term: "beta".into(),
470 postings: vec![NativeFulltextPosting {
471 entity_id: 100,
472 term_count: 5,
473 }],
474 },
475 ],
476 };
477 let encoded = encode_native_fulltext_frame(&frame);
478 assert_eq!(&encoded[0..4], b"RDFT");
479 let decoded = decode_native_fulltext_frame(&encoded).unwrap();
480 assert_eq!(decoded, frame);
481 assert_eq!(encode_native_fulltext_frame(&decoded), encoded);
482 }
483
484 #[test]
485 fn rddp_round_trips_and_validates_total() {
486 let frame = NativeDocPathValueFrame {
487 collection: "docs".into(),
488 docs: vec![
489 NativeDocPathValue {
490 entity_id: 9,
491 entries: vec![
492 NativeDocPathValueEntry {
493 path: "a.b".into(),
494 value: "1".into(),
495 },
496 NativeDocPathValueEntry {
497 path: "a.c".into(),
498 value: "2".into(),
499 },
500 ],
501 },
502 NativeDocPathValue {
503 entity_id: 10,
504 entries: vec![NativeDocPathValueEntry {
505 path: "x".into(),
506 value: "y".into(),
507 }],
508 },
509 ],
510 };
511 let encoded = encode_native_doc_pathvalue_frame(&frame);
512 assert_eq!(&encoded[0..4], b"RDDP");
513 let decoded = decode_native_doc_pathvalue_frame(&encoded).unwrap();
514 assert_eq!(decoded, frame);
515 assert_eq!(encode_native_doc_pathvalue_frame(&decoded), encoded);
516 }
517
518 #[test]
519 fn rddp_entity_id_is_fixed_u64() {
520 let frame = NativeDocPathValueFrame {
524 collection: String::new(),
525 docs: vec![NativeDocPathValue {
526 entity_id: 0xDEAD_BEEF_CAFE_F00D,
527 entries: vec![],
528 }],
529 };
530 let encoded = encode_native_doc_pathvalue_frame(&frame);
531 let id_off = 16;
533 assert_eq!(
534 &encoded[id_off..id_off + 8],
535 &0xDEAD_BEEF_CAFE_F00Du64.to_le_bytes()
536 );
537 let decoded = decode_native_doc_pathvalue_frame(&encoded).unwrap();
538 assert_eq!(decoded.docs[0].entity_id, 0xDEAD_BEEF_CAFE_F00D);
539 }
540
541 #[test]
542 fn native_artifacts_reject_bad_magic() {
543 assert!(matches!(
544 decode_native_graph_adjacency_frame(&[0u8; 8]),
545 Err(NativeArtifactFrameError::InvalidMagic { .. })
546 ));
547 assert!(matches!(
548 decode_native_fulltext_frame(&[0u8; 12]),
549 Err(NativeArtifactFrameError::InvalidMagic { .. })
550 ));
551 assert!(matches!(
552 decode_native_doc_pathvalue_frame(&[0u8; 12]),
553 Err(NativeArtifactFrameError::InvalidMagic { .. })
554 ));
555 }
556
557 #[test]
558 fn native_artifacts_do_not_preallocate_untrusted_counts() {
559 let mut encoded = Vec::new();
560 encoded.extend_from_slice(&NATIVE_GRAPH_ADJACENCY_MAGIC);
561 encoded.extend_from_slice(&u32::MAX.to_le_bytes());
562
563 assert!(matches!(
564 decode_native_graph_adjacency_frame(&encoded),
565 Err(NativeArtifactFrameError::Truncated { .. })
566 ));
567 }
568
569 #[test]
570 fn rddp_rejects_total_entries_mismatch() {
571 let frame = NativeDocPathValueFrame {
572 collection: "c".into(),
573 docs: vec![NativeDocPathValue {
574 entity_id: 1,
575 entries: vec![NativeDocPathValueEntry {
576 path: "p".into(),
577 value: "v".into(),
578 }],
579 }],
580 };
581 let mut encoded = encode_native_doc_pathvalue_frame(&frame);
582 let total_off = 4 + 4 + frame.collection.len() + 4;
585 encoded[total_off..total_off + 4].copy_from_slice(&9u32.to_le_bytes());
586 assert!(matches!(
587 decode_native_doc_pathvalue_frame(&encoded),
588 Err(NativeArtifactFrameError::EntryCountMismatch { .. })
589 ));
590 }
591}