1use super::format::{
11 SegmentFooter, SegmentHeader, TileEntry, TileKind, framing::BlockFraming, header::HEADER_SIZE,
12};
13use crate::codec::tag::peek_tag;
14use crate::codec::tile_decode::decode_sparse_tile;
15use crate::error::{ArrayError, ArrayResult};
16use crate::tile::cell_payload::{CELL_GDPR_ERASURE_SENTINEL, CELL_TOMBSTONE_SENTINEL, CellPayload};
17use crate::tile::dense_tile::DenseTile;
18use crate::tile::sparse_tile::{RowKind, SparseTile};
19use crate::types::coord::value::CoordValue;
20
21pub fn extract_cell_bytes(tile: &SparseTile, coord: &[CoordValue]) -> ArrayResult<Option<Vec<u8>>> {
32 let n = tile.row_count();
33 'rows: for row in 0..n {
34 for (dim_idx, c) in coord.iter().enumerate() {
36 let dict =
37 tile.dim_dicts
38 .get(dim_idx)
39 .ok_or_else(|| ArrayError::SegmentCorruption {
40 detail: format!("extract_cell_bytes: dim {dim_idx} out of range"),
41 })?;
42 let entry_idx = *dict
43 .indices
44 .get(row)
45 .ok_or_else(|| ArrayError::SegmentCorruption {
46 detail: format!("extract_cell_bytes: row {row} index out of range"),
47 })? as usize;
48 let stored =
49 dict.values
50 .get(entry_idx)
51 .ok_or_else(|| ArrayError::SegmentCorruption {
52 detail: format!("extract_cell_bytes: dict entry {entry_idx} out of range"),
53 })?;
54 if stored != c {
55 continue 'rows;
56 }
57 }
58 let kind = tile.row_kind(row)?;
60 match kind {
61 RowKind::Tombstone => return Ok(Some(CELL_TOMBSTONE_SENTINEL.to_vec())),
62 RowKind::GdprErased => return Ok(Some(CELL_GDPR_ERASURE_SENTINEL.to_vec())),
63 RowKind::Live => {}
64 }
65 let mut live_row = 0usize;
67 for earlier in 0..row {
68 if tile.row_kind(earlier)? == RowKind::Live {
69 live_row += 1;
70 }
71 }
72 let attrs: Vec<_> = tile
73 .attr_cols
74 .iter()
75 .map(|col| {
76 col.get(live_row)
77 .cloned()
78 .ok_or_else(|| ArrayError::SegmentCorruption {
79 detail: format!(
80 "extract_cell_bytes: attr col live row {live_row} out of range"
81 ),
82 })
83 })
84 .collect::<ArrayResult<Vec<_>>>()?;
85 let surrogate = tile.live_surrogate(row)?;
86 let valid_from_ms =
87 tile.valid_from_ms
88 .get(row)
89 .copied()
90 .ok_or_else(|| ArrayError::SegmentCorruption {
91 detail: format!("extract_cell_bytes: valid_from_ms row {row} out of range"),
92 })?;
93 let valid_until_ms =
94 tile.valid_until_ms
95 .get(row)
96 .copied()
97 .ok_or_else(|| ArrayError::SegmentCorruption {
98 detail: format!("extract_cell_bytes: valid_until_ms row {row} out of range"),
99 })?;
100 let payload = CellPayload {
101 valid_from_ms,
102 valid_until_ms,
103 attrs,
104 surrogate,
105 };
106 return payload.encode().map(Some);
107 }
108 Ok(None)
109}
110
111fn read_sparse_tile(payload: &[u8]) -> ArrayResult<SparseTile> {
116 match peek_tag(payload) {
117 Some(_) => decode_sparse_tile(payload),
118 None => Err(ArrayError::SegmentCorruption {
119 detail: format!(
120 "sparse tile has unrecognized tag byte {:#04x}",
121 payload.first().copied().unwrap_or(0)
122 ),
123 }),
124 }
125}
126
127#[derive(Debug, Clone, PartialEq)]
129pub enum TilePayload {
130 Sparse(SparseTile),
131 Dense(DenseTile),
132}
133
134pub struct SegmentReader<'a> {
135 bytes: &'a [u8],
136 header: SegmentHeader,
137 footer: SegmentFooter,
138}
139
140impl<'a> SegmentReader<'a> {
141 pub fn open(bytes: &'a [u8]) -> ArrayResult<Self> {
142 if bytes.len() < HEADER_SIZE {
143 return Err(ArrayError::SegmentCorruption {
144 detail: format!("segment too small: {} bytes", bytes.len()),
145 });
146 }
147 let header = SegmentHeader::decode(&bytes[..HEADER_SIZE])?;
148 let footer = SegmentFooter::decode(bytes)?;
149 if header.schema_hash != footer.schema_hash {
150 return Err(ArrayError::SegmentCorruption {
151 detail: format!(
152 "header/footer schema_hash mismatch: header={:x} footer={:x}",
153 header.schema_hash, footer.schema_hash
154 ),
155 });
156 }
157 Ok(Self {
158 bytes,
159 header,
160 footer,
161 })
162 }
163
164 pub fn header(&self) -> &SegmentHeader {
165 &self.header
166 }
167
168 pub fn schema_hash(&self) -> u64 {
169 self.header.schema_hash
170 }
171
172 pub fn tiles(&self) -> &[TileEntry] {
173 &self.footer.tiles
174 }
175
176 pub fn tile_count(&self) -> usize {
177 self.footer.tiles.len()
178 }
179
180 pub fn read_tile_as_of(
190 &self,
191 hilbert_prefix: u64,
192 system_as_of: i64,
193 _valid_at_ms: Option<i64>,
194 ) -> ArrayResult<Option<TilePayload>> {
195 let tiles = &self.footer.tiles;
196
197 let first = tiles.partition_point(|e| e.tile_id.hilbert_prefix < hilbert_prefix);
199 let past_prefix = tiles.partition_point(|e| e.tile_id.hilbert_prefix <= hilbert_prefix);
203
204 let candidates = &tiles[first..past_prefix];
206 if candidates.is_empty() {
207 return Ok(None);
208 }
209
210 let cutoff_pos = candidates.partition_point(|e| e.tile_id.system_from_ms <= system_as_of);
213 if cutoff_pos == 0 {
214 return Ok(None);
215 }
216
217 let entry_idx = first + cutoff_pos - 1;
219 self.read_tile(entry_idx).map(Some)
220 }
221
222 pub fn iter_tile_versions(
229 &self,
230 hilbert_prefix: u64,
231 system_as_of: i64,
232 ) -> ArrayResult<impl Iterator<Item = ArrayResult<(crate::types::TileId, TilePayload)>> + '_>
233 {
234 let tiles = &self.footer.tiles;
235
236 let first = tiles.partition_point(|e| e.tile_id.hilbert_prefix < hilbert_prefix);
238 let past_prefix = tiles.partition_point(|e| e.tile_id.hilbert_prefix <= hilbert_prefix);
239
240 let cutoff_pos =
243 tiles[first..past_prefix].partition_point(|e| e.tile_id.system_from_ms <= system_as_of);
244 let qualifying_start = first;
246 let qualifying_end = first + cutoff_pos;
247
248 let indices: Vec<usize> = (qualifying_start..qualifying_end).rev().collect();
250 Ok(indices.into_iter().map(move |idx| {
251 let tile_id = tiles[idx].tile_id;
252 self.read_tile(idx).map(|payload| (tile_id, payload))
253 }))
254 }
255
256 pub fn read_tile(&self, idx: usize) -> ArrayResult<TilePayload> {
258 let entry = self
259 .footer
260 .tiles
261 .get(idx)
262 .ok_or_else(|| ArrayError::SegmentCorruption {
263 detail: format!(
264 "tile index {idx} out of range (have {})",
265 self.footer.tiles.len()
266 ),
267 })?;
268 let off = entry.offset as usize;
269 let len = entry.length as usize;
270 let end = off
271 .checked_add(len)
272 .ok_or_else(|| ArrayError::SegmentCorruption {
273 detail: "tile entry offset+length overflows".into(),
274 })?;
275 if end > self.bytes.len() {
276 return Err(ArrayError::SegmentCorruption {
277 detail: format!(
278 "tile {idx} block out of bounds: off={off} len={len} \
279 file_size={}",
280 self.bytes.len()
281 ),
282 });
283 }
284 let (payload, _) = BlockFraming::decode(&self.bytes[off..end])?;
285 match entry.kind {
286 TileKind::Sparse => {
287 let t = read_sparse_tile(payload)?;
288 Ok(TilePayload::Sparse(t))
289 }
290 TileKind::Dense => {
291 let t: DenseTile =
292 zerompk::from_msgpack(payload).map_err(|e| ArrayError::SegmentCorruption {
293 detail: format!("dense tile decode failed: {e}"),
294 })?;
295 Ok(TilePayload::Dense(t))
296 }
297 }
298 }
299}
300
301#[derive(Debug)]
307pub struct OwnedSegmentReader {
308 plaintext: Vec<u8>,
310 header: SegmentHeader,
311 footer: SegmentFooter,
312}
313
314impl OwnedSegmentReader {
315 pub fn open_with_kek(
323 blob: &[u8],
324 kek: Option<&nodedb_wal::crypto::WalEncryptionKey>,
325 ) -> ArrayResult<Self> {
326 use super::encrypt::{decrypt_segment, detect_encryption};
327 let is_encrypted = detect_encryption(blob)?;
328 let plaintext = match (is_encrypted, kek) {
329 (true, Some(key)) => decrypt_segment(key, blob)?,
330 (true, None) => return Err(ArrayError::MissingKek),
331 (false, Some(_)) => return Err(ArrayError::KekRequired),
332 (false, None) => blob.to_vec(),
333 };
334 let header = SegmentHeader::decode(&plaintext[..HEADER_SIZE.min(plaintext.len())])?;
335 let footer = SegmentFooter::decode(&plaintext)?;
336 if header.schema_hash != footer.schema_hash {
337 return Err(ArrayError::SegmentCorruption {
338 detail: format!(
339 "header/footer schema_hash mismatch: header={:x} footer={:x}",
340 header.schema_hash, footer.schema_hash
341 ),
342 });
343 }
344 Ok(Self {
345 plaintext,
346 header,
347 footer,
348 })
349 }
350
351 pub fn reader(&self) -> SegmentReader<'_> {
353 SegmentReader {
354 bytes: &self.plaintext,
355 header: self.header,
356 footer: self.footer.clone(),
357 }
358 }
359
360 pub fn into_plaintext(self) -> Vec<u8> {
362 self.plaintext
363 }
364}
365
366impl<'a> SegmentReader<'a> {
367 pub fn open_with_kek(
375 blob: &[u8],
376 kek: Option<&nodedb_wal::crypto::WalEncryptionKey>,
377 ) -> ArrayResult<OwnedSegmentReader> {
378 OwnedSegmentReader::open_with_kek(blob, kek)
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use crate::schema::ArraySchemaBuilder;
386 use crate::schema::attr_spec::{AttrSpec, AttrType};
387 use crate::schema::dim_spec::{DimSpec, DimType};
388 use crate::segment::writer::SegmentWriter;
389 use crate::tile::dense_tile::DenseTile;
390 use crate::tile::sparse_tile::SparseTileBuilder;
391 use crate::types::TileId;
392 use crate::types::cell_value::value::CellValue;
393 use crate::types::coord::value::CoordValue;
394 use crate::types::domain::{Domain, DomainBound};
395
396 fn schema() -> crate::schema::ArraySchema {
397 ArraySchemaBuilder::new("g")
398 .dim(DimSpec::new(
399 "x",
400 DimType::Int64,
401 Domain::new(DomainBound::Int64(0), DomainBound::Int64(15)),
402 ))
403 .dim(DimSpec::new(
404 "y",
405 DimType::Int64,
406 Domain::new(DomainBound::Int64(0), DomainBound::Int64(15)),
407 ))
408 .attr(AttrSpec::new("v", AttrType::Int64, true))
409 .tile_extents(vec![4, 4])
410 .build()
411 .unwrap()
412 }
413
414 fn make_sparse(s: &crate::schema::ArraySchema, base: i64) -> SparseTile {
415 let mut b = SparseTileBuilder::new(s);
416 b.push_row(crate::tile::sparse_tile::SparseRow::live(
417 &[CoordValue::Int64(base), CoordValue::Int64(base + 1)],
418 &[CellValue::Int64(base * 10)],
419 nodedb_types::Surrogate::new(base as u32 + 1),
420 0,
421 nodedb_types::OPEN_UPPER,
422 ))
423 .unwrap();
424 b.build()
425 }
426
427 #[test]
428 fn reader_round_trips_sparse_tiles() {
429 let s = schema();
430 let mut w = SegmentWriter::new(0xCAFE);
431 w.append_sparse(TileId::snapshot(1), &make_sparse(&s, 1))
432 .unwrap();
433 w.append_sparse(TileId::snapshot(2), &make_sparse(&s, 2))
434 .unwrap();
435 let bytes = w.finish(None).unwrap();
436 let r = SegmentReader::open(&bytes).unwrap();
437 assert_eq!(r.tile_count(), 2);
438 let t0 = r.read_tile(0).unwrap();
439 match t0 {
440 TilePayload::Sparse(t) => assert_eq!(t.nnz(), 1),
441 _ => panic!("expected sparse"),
442 }
443 }
444
445 #[test]
446 fn reader_round_trips_dense_tile() {
447 let s = schema();
448 let mut w = SegmentWriter::new(0xBEEF);
449 w.append_dense(TileId::snapshot(1), &DenseTile::empty(&s))
450 .unwrap();
451 let bytes = w.finish(None).unwrap();
452 let r = SegmentReader::open(&bytes).unwrap();
453 match r.read_tile(0).unwrap() {
454 TilePayload::Dense(t) => assert_eq!(t.cell_count(), 16),
455 _ => panic!("expected dense"),
456 }
457 }
458
459 #[test]
460 fn reader_rejects_mismatched_schema_hash() {
461 let s = schema();
467 let mut w = SegmentWriter::new(0x1);
468 w.append_sparse(TileId::snapshot(1), &make_sparse(&s, 1))
469 .unwrap();
470 let mut bytes = w.finish(None).unwrap();
471 bytes[12] ^= 0xFF; assert!(SegmentReader::open(&bytes).is_err());
473 }
474
475 #[test]
476 fn reader_rejects_out_of_range_tile() {
477 let s = schema();
478 let mut w = SegmentWriter::new(0x1);
479 w.append_sparse(TileId::snapshot(1), &make_sparse(&s, 1))
480 .unwrap();
481 let bytes = w.finish(None).unwrap();
482 let r = SegmentReader::open(&bytes).unwrap();
483 assert!(r.read_tile(99).is_err());
484 }
485
486 #[test]
487 fn read_tile_as_of_returns_newest_at_cutoff() {
488 let s = schema();
489 let mut w = SegmentWriter::new(0xCAFE);
490 w.append_sparse(TileId::new(1, 100), &make_sparse(&s, 1))
492 .unwrap();
493 w.append_sparse(TileId::new(1, 200), &make_sparse(&s, 2))
494 .unwrap();
495 w.append_sparse(TileId::new(1, 300), &make_sparse(&s, 3))
496 .unwrap();
497 let bytes = w.finish(None).unwrap();
498 let r = SegmentReader::open(&bytes).unwrap();
499 let result = r.read_tile_as_of(1, 250, None).unwrap();
501 match result {
502 Some(TilePayload::Sparse(t)) => {
503 assert_eq!(t.nnz(), 1);
505 assert_eq!(t.dim_dicts[0].values[0], CoordValue::Int64(2));
507 }
508 other => panic!("expected Some(Sparse), got {other:?}"),
509 }
510 }
511
512 #[test]
513 fn read_tile_as_of_returns_none_below_first_version() {
514 let s = schema();
515 let mut w = SegmentWriter::new(0xBEEF);
516 w.append_sparse(TileId::new(1, 100), &make_sparse(&s, 1))
517 .unwrap();
518 let bytes = w.finish(None).unwrap();
519 let r = SegmentReader::open(&bytes).unwrap();
520 let result = r.read_tile_as_of(1, 50, None).unwrap();
522 assert!(result.is_none());
523 }
524
525 #[test]
526 fn extract_cell_bytes_finds_coord() {
527 let s = schema();
528 let sparse = make_sparse(&s, 3);
529 let coord = vec![CoordValue::Int64(3), CoordValue::Int64(4)];
531 let bytes = extract_cell_bytes(&sparse, &coord).unwrap();
532 assert!(bytes.is_some(), "should find coord (3,4)");
533
534 let absent = vec![CoordValue::Int64(9), CoordValue::Int64(9)];
535 let none = extract_cell_bytes(&sparse, &absent).unwrap();
536 assert!(none.is_none(), "absent coord must return None");
537 }
538
539 #[test]
540 fn extract_cell_bytes_carries_valid_time_bounds() {
541 use crate::tile::cell_payload::CellPayload;
542 use crate::tile::sparse_tile::{SparseRow, SparseTileBuilder};
543 use nodedb_types::Surrogate;
544
545 let s = schema();
546 let mut b = SparseTileBuilder::new(&s);
547 b.push_row(SparseRow {
548 coord: &[CoordValue::Int64(1), CoordValue::Int64(2)],
549 attrs: &[CellValue::Int64(99)],
550 surrogate: Some(Surrogate::new(3)),
551 valid_from_ms: 100,
552 valid_until_ms: 200,
553 kind: crate::tile::sparse_tile::RowKind::Live,
554 })
555 .unwrap();
556 let tile = b.build();
557
558 let coord = vec![CoordValue::Int64(1), CoordValue::Int64(2)];
559 let bytes = extract_cell_bytes(&tile, &coord).unwrap().unwrap();
560 let payload = CellPayload::decode(&bytes).unwrap();
561 assert_eq!(payload.valid_from_ms, 100);
562 assert_eq!(payload.valid_until_ms, 200);
563 }
564
565 #[test]
566 fn iter_tile_versions_newest_first() {
567 let s = schema();
568 let mut w = SegmentWriter::new(0xCAFE);
569 w.append_sparse(TileId::new(1, 100), &make_sparse(&s, 1))
570 .unwrap();
571 w.append_sparse(TileId::new(1, 200), &make_sparse(&s, 2))
572 .unwrap();
573 w.append_sparse(TileId::new(1, 300), &make_sparse(&s, 3))
574 .unwrap();
575 let bytes = w.finish(None).unwrap();
576 let r = SegmentReader::open(&bytes).unwrap();
577 let versions: Vec<_> = r
578 .iter_tile_versions(1, i64::MAX)
579 .unwrap()
580 .map(|v| v.unwrap().0.system_from_ms)
581 .collect();
582 assert_eq!(versions, vec![300, 200, 100]);
583 }
584
585 #[test]
586 fn iter_tile_versions_respects_system_as_of() {
587 let s = schema();
588 let mut w = SegmentWriter::new(0xBEEF);
589 w.append_sparse(TileId::new(1, 100), &make_sparse(&s, 1))
590 .unwrap();
591 w.append_sparse(TileId::new(1, 200), &make_sparse(&s, 2))
592 .unwrap();
593 w.append_sparse(TileId::new(1, 300), &make_sparse(&s, 3))
594 .unwrap();
595 let bytes = w.finish(None).unwrap();
596 let r = SegmentReader::open(&bytes).unwrap();
597 let versions: Vec<_> = r
598 .iter_tile_versions(1, 250)
599 .unwrap()
600 .map(|v| v.unwrap().0.system_from_ms)
601 .collect();
602 assert_eq!(versions, vec![200, 100]);
604 }
605
606 #[test]
607 fn extract_cell_bytes_returns_tombstone_sentinel_for_tombstone_row() {
608 use crate::tile::cell_payload::{CELL_TOMBSTONE_SENTINEL, is_cell_tombstone};
609 use crate::tile::sparse_tile::{RowKind, SparseTileBuilder};
610
611 let s = schema();
612 let mut b = SparseTileBuilder::new(&s);
613 b.push_row(crate::tile::sparse_tile::SparseRow {
614 coord: &[CoordValue::Int64(7), CoordValue::Int64(8)],
615 attrs: &[],
616 surrogate: None,
617 valid_from_ms: 0,
618 valid_until_ms: nodedb_types::OPEN_UPPER,
619 kind: RowKind::Tombstone,
620 })
621 .unwrap();
622 let tile = b.build();
623 let coord = vec![CoordValue::Int64(7), CoordValue::Int64(8)];
624 let bytes = extract_cell_bytes(&tile, &coord).unwrap().unwrap();
625 assert!(
626 is_cell_tombstone(&bytes),
627 "expected CELL_TOMBSTONE_SENTINEL, got {bytes:?}"
628 );
629 assert_eq!(bytes, CELL_TOMBSTONE_SENTINEL);
630 }
631
632 #[test]
635 fn extract_cell_bytes_reads_a_live_row_after_a_sentinel() {
636 use crate::tile::sparse_tile::{RowKind, SparseRow, SparseTileBuilder};
637 use nodedb_types::Surrogate;
638
639 let s = schema();
640 let mut b = SparseTileBuilder::new(&s);
641 b.push_row(SparseRow {
642 coord: &[CoordValue::Int64(1), CoordValue::Int64(1)],
643 attrs: &[],
644 surrogate: None,
645 valid_from_ms: 0,
646 valid_until_ms: nodedb_types::OPEN_UPPER,
647 kind: RowKind::Tombstone,
648 })
649 .unwrap();
650 b.push_row(SparseRow {
651 coord: &[CoordValue::Int64(2), CoordValue::Int64(2)],
652 attrs: &[CellValue::Int64(20)],
653 surrogate: Some(Surrogate::new(9)),
654 valid_from_ms: 5,
655 valid_until_ms: nodedb_types::OPEN_UPPER,
656 kind: RowKind::Live,
657 })
658 .unwrap();
659 let tile = b.build();
660 let coord = vec![CoordValue::Int64(2), CoordValue::Int64(2)];
661 let bytes = extract_cell_bytes(&tile, &coord).unwrap().unwrap();
662 let payload = CellPayload::decode(&bytes).unwrap();
663 assert_eq!(payload.attrs, vec![CellValue::Int64(20)]);
664 assert_eq!(payload.surrogate, Surrogate::new(9));
665 }
666
667 #[test]
668 fn extract_cell_bytes_returns_erasure_sentinel_for_erased_row() {
669 use crate::tile::cell_payload::{CELL_GDPR_ERASURE_SENTINEL, is_cell_gdpr_erasure};
670 use crate::tile::sparse_tile::{RowKind, SparseTileBuilder};
671
672 let s = schema();
673 let mut b = SparseTileBuilder::new(&s);
674 b.push_row(crate::tile::sparse_tile::SparseRow {
675 coord: &[CoordValue::Int64(4), CoordValue::Int64(5)],
676 attrs: &[],
677 surrogate: None,
678 valid_from_ms: 0,
679 valid_until_ms: nodedb_types::OPEN_UPPER,
680 kind: RowKind::GdprErased,
681 })
682 .unwrap();
683 let tile = b.build();
684 let coord = vec![CoordValue::Int64(4), CoordValue::Int64(5)];
685 let bytes = extract_cell_bytes(&tile, &coord).unwrap().unwrap();
686 assert!(
687 is_cell_gdpr_erasure(&bytes),
688 "expected CELL_GDPR_ERASURE_SENTINEL, got {bytes:?}"
689 );
690 assert_eq!(bytes, CELL_GDPR_ERASURE_SENTINEL);
691 }
692
693 #[test]
694 fn read_tile_as_of_finds_exact_match() {
695 let s = schema();
696 let mut w = SegmentWriter::new(0xDEAD);
697 w.append_sparse(TileId::new(1, 100), &make_sparse(&s, 1))
698 .unwrap();
699 w.append_sparse(TileId::new(1, 200), &make_sparse(&s, 2))
700 .unwrap();
701 w.append_sparse(TileId::new(1, 300), &make_sparse(&s, 3))
702 .unwrap();
703 let bytes = w.finish(None).unwrap();
704 let r = SegmentReader::open(&bytes).unwrap();
705 let result = r.read_tile_as_of(1, 200, None).unwrap();
707 match result {
708 Some(TilePayload::Sparse(t)) => {
709 assert_eq!(t.nnz(), 1);
710 assert_eq!(t.dim_dicts[0].values[0], CoordValue::Int64(2));
711 }
712 other => panic!("expected Some(Sparse), got {other:?}"),
713 }
714 }
715
716 fn test_kek() -> nodedb_wal::crypto::WalEncryptionKey {
717 nodedb_wal::crypto::WalEncryptionKey::from_bytes(&[0xA1u8; 32]).unwrap()
718 }
719
720 fn write_plain(s: &crate::schema::ArraySchema, id: TileId) -> Vec<u8> {
721 let mut w = SegmentWriter::new(0xCAFE);
722 w.append_sparse(id, &make_sparse(s, 1)).unwrap();
723 w.finish(None).unwrap()
724 }
725
726 fn write_encrypted(s: &crate::schema::ArraySchema, id: TileId) -> Vec<u8> {
727 let kek = test_kek();
728 let mut w = SegmentWriter::new(0xCAFE);
729 w.append_sparse(id, &make_sparse(s, 1)).unwrap();
730 w.finish(Some(&kek)).unwrap()
731 }
732
733 #[test]
734 fn array_segment_refuses_plaintext_with_kek() {
735 let s = schema();
736 let plain = write_plain(&s, TileId::snapshot(1));
737 let kek = test_kek();
738 let err = OwnedSegmentReader::open_with_kek(&plain, Some(&kek)).unwrap_err();
739 assert!(
740 matches!(err, crate::error::ArrayError::KekRequired),
741 "expected KekRequired, got {err:?}"
742 );
743 }
744
745 #[test]
746 fn array_segment_refuses_encrypted_without_kek() {
747 let s = schema();
748 let encrypted = write_encrypted(&s, TileId::snapshot(1));
749 let err = OwnedSegmentReader::open_with_kek(&encrypted, None).unwrap_err();
750 assert!(
751 matches!(err, crate::error::ArrayError::MissingKek),
752 "expected MissingKek, got {err:?}"
753 );
754 }
755
756 #[test]
757 fn array_segment_tampered_ciphertext_rejected() {
758 let s = schema();
759 let mut encrypted = write_encrypted(&s, TileId::snapshot(1));
760 encrypted[nodedb_wal::crypto::SEGMENT_ENVELOPE_PREAMBLE_SIZE + 2] ^= 0xFF;
762 let kek = test_kek();
763 assert!(OwnedSegmentReader::open_with_kek(&encrypted, Some(&kek)).is_err());
764 }
765
766 #[test]
767 fn array_segment_encrypted_at_rest() {
768 let s = schema();
769 let encrypted = write_encrypted(&s, TileId::snapshot(1));
770 assert_eq!(&encrypted[..4], b"SEGA");
772 let kek = test_kek();
773 let owned = OwnedSegmentReader::open_with_kek(&encrypted, Some(&kek)).unwrap();
774 let reader = owned.reader();
775 assert_eq!(reader.tile_count(), 1);
776 }
777
778 #[test]
779 fn array_segment_handle_decrypts_into_owned_buffer() {
780 let s = schema();
781 let kek = test_kek();
782 let mut w = SegmentWriter::new(0x1234);
783 w.append_sparse(TileId::new(1, 100), &make_sparse(&s, 1))
784 .unwrap();
785 w.append_sparse(TileId::new(2, 200), &make_sparse(&s, 2))
786 .unwrap();
787 let encrypted = w.finish(Some(&kek)).unwrap();
788 let owned = OwnedSegmentReader::open_with_kek(&encrypted, Some(&kek)).unwrap();
789 let reader = owned.reader();
790 assert_eq!(reader.tile_count(), 2);
791 assert_eq!(reader.tiles()[0].tile_id, TileId::new(1, 100));
792 }
793}