1use std::ffi::{OsStr, OsString};
31use std::fs::{self, OpenOptions};
32use std::io::{self, BufReader, Read, Seek, SeekFrom, Write};
33use std::path::{Component, Path, PathBuf};
34use std::sync::atomic::{AtomicU64, Ordering};
35
36use crate::engine_contract::{Attrs, EntryKind, Error, Observation, Op, Result, Source};
37use crate::index::{EntryId, Index, IndexHandle};
38use crate::stored_state::{
39 ControlTierIdentity, SNAPSHOT_IDENTITY_BYTES, Serves, SnapshotIdentity, serves_snapshot,
40};
41
42#[derive(Debug)]
44#[allow(clippy::large_enum_variant)] pub enum LoadOutcome {
46 Served {
48 index: Index,
50 stored: SnapshotIdentity,
53 },
54 Refused {
56 identity: SnapshotIdentity,
58 root: PathBuf,
60 },
61 Absent,
63}
64
65const MAGIC: &[u8; 8] = b"FDUSNAP\x00";
67
68const TRAILER: &[u8; 8] = b"FDUEND\x00\x00";
71
72const CHECKSUM_BYTES: usize = std::mem::size_of::<u32>();
75
76const CRC32C_POLYNOMIAL: u32 = 0x82f6_3b78;
78
79const CRC32C_TABLES: [[u32; 256]; 8] = make_crc32c_tables();
81
82const FORMAT_VERSION: u32 = 6;
100
101const CLASSIFICATION_VERSION: u32 = 2;
111
112const VALIDITY_VERSION: u32 = 2;
119
120#[cfg(unix)]
122const PATH_ENCODING_UNIX_BYTES: u8 = 1;
123
124#[cfg(windows)]
126const PATH_ENCODING_WINDOWS_WIDE: u8 = 2;
127
128#[cfg(not(any(unix, windows)))]
130const PATH_ENCODING_UTF8: u8 = 3;
131
132const NO_PARENT: u32 = u32::MAX;
134
135const WRITING_PASS_STARTED_AT_OFFSET: usize = MAGIC.len() + 4 + 8 + 1;
141
142const IDENTITY_OFFSET: usize = WRITING_PASS_STARTED_AT_OFFSET + 8;
144
145#[cfg(test)]
147const ROOT_OFFSET: usize = IDENTITY_OFFSET + SNAPSHOT_IDENTITY_BYTES;
148
149const MIN_RECORD_BYTES: usize = 4 + 1 + 4 + 8 * 6;
153
154const GIBIBYTE: u64 = 1024 * 1024 * 1024;
156
157const MAX_SNAPSHOT_BYTES: u64 = 64 * GIBIBYTE;
159
160static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(0);
162
163static TEMP_FILE_ENTROPY: std::sync::LazyLock<u64> = std::sync::LazyLock::new(|| {
180 use std::hash::{BuildHasher, Hasher};
181 std::collections::hash_map::RandomState::new().build_hasher().finish()
182});
183
184const MAX_TEMP_CREATE_ATTEMPTS: usize = 1024;
187
188pub(crate) const STALE_TEMP_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
204
205const MAX_PATH_BYTES: u32 = 1024 * 1024;
207
208const MAX_SNAPSHOT_ENTRIES: u64 = 100_000_000;
210
211const MEBIBYTE: usize = 1024 * 1024;
213
214const SNAPSHOT_CONTROL_TABLE_CEILING: usize = 256 * MEBIBYTE;
222
223const REFUSED_FOR_BUDGET: u8 = 1;
225const REFUSED_FOR_LINE_LIMIT: u8 = 2;
226
227pub fn engine_fingerprint() -> u64 {
238 engine_fingerprint_under(crate::stored_state::IGNORE_RULES_VERSION)
239}
240
241fn engine_fingerprint_under(ignore_rules_version: u64) -> u64 {
247 let mut hash = 0xcbf2_9ce4_8422_2325_u64;
248 let mut mix = |bytes: &[u8]| {
249 for byte in bytes {
250 hash ^= u64::from(*byte);
251 hash = hash.wrapping_mul(0x1000_0000_01b3);
252 }
253 };
254 mix(env!("CARGO_PKG_VERSION").as_bytes());
255 mix(&FORMAT_VERSION.to_le_bytes());
256 mix(&CLASSIFICATION_VERSION.to_le_bytes());
257 mix(&VALIDITY_VERSION.to_le_bytes());
258 mix(&ignore_rules_version.to_le_bytes());
259 hash
260}
261
262pub fn save(index: &Index, path: &Path) -> Result<()> {
264 debug_assert!(!index.is_folded(), "a folded index omits files, so it is never persisted");
265 if !crate::stored_state::entries_writable(index) {
266 return Err(Error::Snapshot(
267 "refusing to persist an index that is stale, reconciling, or incomplete".into(),
268 ));
269 }
270 index.require_control_limits_in_scope(index.control_table().limits())?;
273 let mut buf: Vec<u8> = Vec::new();
274 buf.extend_from_slice(MAGIC);
275 buf.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
276 buf.extend_from_slice(&engine_fingerprint().to_le_bytes());
277 buf.push(path_encoding());
278 debug_assert_eq!(buf.len(), WRITING_PASS_STARTED_AT_OFFSET);
279 buf.extend_from_slice(&index.writing_pass_started_at_ns().to_le_bytes());
280 buf.extend_from_slice(&index.snapshot_identity().encode());
282
283 put_os_str(&mut buf, index.root_path().as_os_str())?;
284
285 let mut records: Vec<(u32, EntryId)> = Vec::new();
288 let mut stack: Vec<(u32, EntryId)> = vec![(NO_PARENT, EntryId::ROOT)];
289 while let Some((parent_slot, id)) = stack.pop() {
290 let slot = u32::try_from(records.len())
291 .map_err(|_| Error::Snapshot("snapshot exceeds u32 entry capacity".into()))?;
292 records.push((parent_slot, id));
293 let children = index
294 .children_of(id)
295 .ok_or_else(|| Error::Snapshot("stale entry handle while saving".into()))?;
296 for (_, child) in children.rev() {
297 stack.push((slot, child));
298 }
299 }
300
301 let count = u64::try_from(records.len())
302 .map_err(|_| Error::Snapshot("snapshot entry count overflow".into()))?;
303 buf.extend_from_slice(&count.to_le_bytes());
304
305 for (parent_slot, id) in records {
306 buf.extend_from_slice(&parent_slot.to_le_bytes());
307 let kind = index
308 .kind_of(id)
309 .ok_or_else(|| Error::Snapshot("stale entry handle while saving".into()))?;
310 buf.push(kind as u8);
311 let name = index
312 .name_of(id)
313 .ok_or_else(|| Error::Snapshot("stale entry handle while saving".into()))?;
314 put_os_str(&mut buf, name)?;
315 let attrs = index
316 .attrs_of(id)
317 .ok_or_else(|| Error::Snapshot("stale entry handle while saving".into()))?;
318 buf.extend_from_slice(&attrs.size.to_le_bytes());
319 buf.extend_from_slice(&attrs.allocated.to_le_bytes());
320 buf.extend_from_slice(&attrs.mtime_ns.to_le_bytes());
321 buf.extend_from_slice(&attrs.ctime_ns.to_le_bytes());
322 buf.extend_from_slice(&attrs.inode.to_le_bytes());
323 buf.extend_from_slice(&attrs.dev.to_le_bytes());
324 }
325 put_controls(&mut buf, index.control_table())?;
326 publish(path, buf)
327}
328
329fn publish(path: &Path, mut payload: Vec<u8>) -> Result<()> {
341 if keep_equivalent_image(path, &payload) {
342 return Ok(());
343 }
344 seal(&mut payload);
345 replace_atomically(path, &payload)
348}
349
350fn keep_equivalent_image(path: &Path, payload: &[u8]) -> bool {
365 const FOOTER_BYTES: usize = CHECKSUM_BYTES + TRAILER.len();
366 let Ok(mut file) = fs::File::open(path) else { return false };
367 let Ok(metadata) = file.metadata() else { return false };
368 let image_len = payload.len().checked_add(FOOTER_BYTES).and_then(|len| u64::try_from(len).ok());
369 if image_len != Some(metadata.len()) {
370 return false;
371 }
372 let (before_stamp, after_stamp) =
373 (&payload[..WRITING_PASS_STARTED_AT_OFFSET], &payload[IDENTITY_OFFSET..]);
374 let mut stamp = [0u8; 8];
375 if !reads_as(&mut file, before_stamp)
376 || file.read_exact(&mut stamp).is_err()
377 || !reads_as(&mut file, after_stamp)
378 {
379 return false;
380 }
381 let checksum =
382 ![before_stamp, &stamp[..], after_stamp].into_iter().fold(u32::MAX, crc32c_update);
383 let mut footer = [0u8; FOOTER_BYTES];
384 if file.read_exact(&mut footer).is_err()
385 || footer[..CHECKSUM_BYTES] != checksum.to_le_bytes()
386 || footer[CHECKSUM_BYTES..] != *TRAILER
387 || !matches!(file.read(&mut [0u8; 1]), Ok(0))
389 {
390 return false;
391 }
392 let pass_started = payload
393 .get(WRITING_PASS_STARTED_AT_OFFSET..IDENTITY_OFFSET)
394 .and_then(|stamp| <[u8; 8]>::try_from(stamp).ok())
395 .map(i64::from_le_bytes)
396 .and_then(|nanos| u64::try_from(nanos).ok())
397 .and_then(|nanos| {
398 std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_nanos(nanos))
399 });
400 if let Some(pass_started) = pass_started {
401 if !matches!(metadata.modified(), Ok(modified) if modified >= pass_started) {
402 let _ = touch(path, pass_started);
404 }
405 }
406 true
407}
408
409fn seal(payload: &mut Vec<u8>) {
411 let checksum = crc32c(payload);
412 payload.extend_from_slice(&checksum.to_le_bytes());
413 payload.extend_from_slice(TRAILER);
414}
415
416pub fn save_handle(index: &IndexHandle, path: &Path) -> Result<()> {
419 let snapshot = index.snapshot()?;
420 save(&snapshot, path)
421}
422
423pub fn load(path: &Path) -> Result<Option<Index>> {
429 load_with_size_limit(path, MAX_SNAPSHOT_BYTES)
430}
431
432pub fn load_with_types(
438 path: &Path,
439 types: std::sync::Arc<crate::classify::TypeRegistry>,
440) -> Result<Option<Index>> {
441 load_with_types_and_size_limit(path, MAX_SNAPSHOT_BYTES, types, None).map(|outcome| {
442 match outcome {
443 LoadOutcome::Served { index, .. } => Some(index),
444 LoadOutcome::Refused { .. } | LoadOutcome::Absent => None,
445 }
446 })
447}
448
449pub fn load_serving(
452 path: &Path,
453 types: std::sync::Arc<crate::classify::TypeRegistry>,
454 wanted: SnapshotIdentity,
455) -> Result<LoadOutcome> {
456 load_with_types_and_size_limit(path, MAX_SNAPSHOT_BYTES, types, Some(wanted))
457}
458
459fn load_with_size_limit(path: &Path, max_snapshot_bytes: u64) -> Result<Option<Index>> {
460 load_with_types_and_size_limit(
461 path,
462 max_snapshot_bytes,
463 crate::classify::TypeRegistry::compiled_shared(),
464 None,
465 )
466 .map(|outcome| match outcome {
467 LoadOutcome::Served { index, .. } => Some(index),
468 LoadOutcome::Refused { .. } | LoadOutcome::Absent => None,
469 })
470}
471
472fn load_with_types_and_size_limit(
473 path: &Path,
474 max_snapshot_bytes: u64,
475 types: std::sync::Arc<crate::classify::TypeRegistry>,
476 wanted: Option<SnapshotIdentity>,
477) -> Result<LoadOutcome> {
478 let mut file = match fs::File::open(path) {
479 Ok(file) => file,
480 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(LoadOutcome::Absent),
481 Err(e) => return Err(Error::io(path, e)),
482 };
483 let file_len = file.metadata().map_err(|e| Error::io(path, e))?.len();
484 let footer_bytes = CHECKSUM_BYTES
485 .checked_add(TRAILER.len())
486 .and_then(|bytes| u64::try_from(bytes).ok())
487 .ok_or_else(|| Error::Snapshot("snapshot footer size overflow".into()))?;
488 if file_len > max_snapshot_bytes || file_len < footer_bytes {
489 return Ok(LoadOutcome::Absent);
490 }
491
492 let footer_offset = i64::try_from(footer_bytes)
493 .map_err(|_| Error::Snapshot("snapshot footer size overflow".into()))?;
494 file.seek(SeekFrom::End(-footer_offset)).map_err(|e| Error::io(path, e))?;
495 let expected_checksum = match read_footer_checksum(&mut file) {
496 Ok(checksum) => checksum,
497 Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => {
498 return Ok(LoadOutcome::Absent);
499 }
500 Err(error) => return Err(Error::io(path, error)),
501 };
502 let mut trailer = [0u8; TRAILER.len()];
503 match file.read_exact(&mut trailer) {
504 Ok(()) if &trailer == TRAILER => {}
505 Ok(()) => return Ok(LoadOutcome::Absent),
506 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(LoadOutcome::Absent),
507 Err(e) => return Err(Error::io(path, e)),
508 }
509 let payload_len = file_len
510 .checked_sub(footer_bytes)
511 .ok_or_else(|| Error::Snapshot("snapshot length underflow".into()))?;
512 file.seek(SeekFrom::Start(0)).map_err(|e| Error::io(path, e))?;
513
514 let mut reader = Crc32cReader::new(BufReader::new(file.take(payload_len)));
523 let outcome = parse_stream(&mut reader, payload_len, types, wanted);
524 match outcome {
525 Ok(outcome) => {
526 if reader.finish() == expected_checksum { Ok(outcome) } else { Ok(LoadOutcome::Absent) }
529 }
530 Err(ParseError::Invalid) => Ok(LoadOutcome::Absent),
531 Err(ParseError::Io(source)) => Err(Error::io(path, source)),
532 }
533}
534
535struct Crc32cReader<R> {
537 inner: R,
538 state: u32,
539}
540
541impl<R: Read> Crc32cReader<R> {
542 fn new(inner: R) -> Self {
543 Self { inner, state: u32::MAX }
544 }
545
546 fn finish(&self) -> u32 {
547 !self.state
548 }
549}
550
551impl<R: Read> Read for Crc32cReader<R> {
552 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
553 let read = self.inner.read(buf)?;
554 self.state = crc32c_update(self.state, &buf[..read]);
555 Ok(read)
556 }
557}
558
559fn read_footer_checksum(reader: &mut impl Read) -> std::io::Result<u32> {
560 let mut bytes = [0u8; CHECKSUM_BYTES];
561 reader.read_exact(&mut bytes)?;
562 Ok(u32::from_le_bytes(bytes))
563}
564
565pub(crate) fn crc32c(bytes: &[u8]) -> u32 {
566 !crc32c_update(u32::MAX, bytes)
567}
568
569fn crc32c_update(mut state: u32, bytes: &[u8]) -> u32 {
575 let mut chunks = bytes.chunks_exact(8);
576 for chunk in &mut chunks {
577 let low = (state ^ u32::from_le_bytes(chunk[..4].try_into().expect("chunk holds 8 bytes")))
578 .to_le_bytes();
579 let high =
580 u32::from_le_bytes(chunk[4..].try_into().expect("chunk holds 8 bytes")).to_le_bytes();
581 state = CRC32C_TABLES[7][usize::from(low[0])]
582 ^ CRC32C_TABLES[6][usize::from(low[1])]
583 ^ CRC32C_TABLES[5][usize::from(low[2])]
584 ^ CRC32C_TABLES[4][usize::from(low[3])]
585 ^ CRC32C_TABLES[3][usize::from(high[0])]
586 ^ CRC32C_TABLES[2][usize::from(high[1])]
587 ^ CRC32C_TABLES[1][usize::from(high[2])]
588 ^ CRC32C_TABLES[0][usize::from(high[3])];
589 }
590 for byte in chunks.remainder() {
591 let index = usize::from(state.to_le_bytes()[0] ^ *byte);
592 state = CRC32C_TABLES[0][index] ^ (state >> 8);
593 }
594 state
595}
596
597const fn make_crc32c_tables() -> [[u32; 256]; 8] {
598 let mut tables = [[0u32; 256]; 8];
599 let mut index = 0usize;
600 let mut value = 0u32;
601 while index < 256 {
602 let mut crc = value;
603 let mut bit = 0;
604 while bit < u8::BITS {
605 crc = (crc >> 1) ^ (CRC32C_POLYNOMIAL & 0u32.wrapping_sub(crc & 1));
606 bit += 1;
607 }
608 tables[0][index] = crc;
609 index += 1;
610 value += 1;
611 }
612 let mut table = 1usize;
615 while table < tables.len() {
616 let mut index = 0usize;
617 while index < 256 {
618 let previous = tables[table - 1][index];
619 tables[table][index] = tables[0][(previous & 0xFF) as usize] ^ (previous >> 8);
620 index += 1;
621 }
622 table += 1;
623 }
624 tables
625}
626
627pub fn read_header(path: &Path) -> Result<Option<crate::cache::SnapshotInfo>> {
634 Ok(match identify(path)? {
635 Some(Identity::Current(info)) => Some(info),
636 Some(Identity::Stale(_) | Identity::Foreign) | None => None,
637 })
638}
639
640#[derive(Debug)]
642pub(crate) enum Identity {
643 Current(crate::cache::SnapshotInfo),
645 Stale(crate::cache::StaleReason),
647 Foreign,
649}
650
651pub(crate) fn identify(path: &Path) -> Result<Option<Identity>> {
662 let file = match fs::File::open(path) {
663 Ok(file) => file,
664 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
665 Err(error) => return Err(Error::io(path, error)),
666 };
667 let trailer_intact = has_intact_trailer(&file).map_err(|error| Error::io(path, error))?;
668 let mut file = file;
670 file.seek(SeekFrom::Start(0)).map_err(|error| Error::io(path, error))?;
671 identify_prologue(&mut BufReader::new(file), trailer_intact)
672 .map(Some)
673 .map_err(|error| Error::io(path, error))
674}
675
676fn identify_prologue(reader: &mut impl Read, trailer_intact: bool) -> io::Result<Identity> {
679 use crate::cache::StaleReason;
680
681 match read_array::<_, 8>(reader) {
682 Ok(magic) if magic == *MAGIC => {}
683 Ok(_) | Err(ParseError::Invalid) => return Ok(Identity::Foreign),
684 Err(ParseError::Io(error)) => return Err(error),
685 }
686 let Some(version) = invalid_as_none(read_u32(reader))? else {
687 return Ok(Identity::Stale(StaleReason::Unreadable));
688 };
689 match version.cmp(&FORMAT_VERSION) {
690 std::cmp::Ordering::Less => {
691 return Ok(Identity::Stale(StaleReason::OlderFormat { version }));
692 }
693 std::cmp::Ordering::Greater => {
694 return Ok(Identity::Stale(StaleReason::NewerFormat { version }));
695 }
696 std::cmp::Ordering::Equal => {}
697 }
698 let engine = match invalid_as_none(read_u64(reader))? {
699 Some(fingerprint) if fingerprint == engine_fingerprint() => fingerprint,
700 Some(_) => return Ok(Identity::Stale(StaleReason::OtherEngine)),
701 None => return Ok(Identity::Stale(StaleReason::Unreadable)),
702 };
703 if !trailer_intact {
704 return Ok(Identity::Stale(StaleReason::Unreadable));
707 }
708 Ok(match invalid_as_none(parse_header_fields(reader, engine))? {
709 Some(header) => Identity::Current(crate::cache::SnapshotInfo {
710 root: header.root,
711 identity: header.identity,
712 entries: header.entries,
713 }),
714 None => Identity::Stale(StaleReason::Unreadable),
715 })
716}
717
718fn invalid_as_none<T>(result: ParseResult<T>) -> io::Result<Option<T>> {
720 match result {
721 Ok(value) => Ok(Some(value)),
722 Err(ParseError::Invalid) => Ok(None),
723 Err(ParseError::Io(error)) => Err(error),
724 }
725}
726
727fn has_intact_trailer(file: &fs::File) -> io::Result<bool> {
732 let footer_bytes = CHECKSUM_BYTES + TRAILER.len();
733 let file_len = file.metadata()?.len();
734 if file_len < u64::try_from(footer_bytes).unwrap_or(u64::MAX) {
735 return Ok(false);
736 }
737
738 let mut handle = file;
739 handle.seek(SeekFrom::End(-(i64::try_from(TRAILER.len()).unwrap_or(0))))?;
740 let mut trailer = [0u8; TRAILER.len()];
741 match handle.read_exact(&mut trailer) {
742 Ok(()) => Ok(&trailer == TRAILER),
743 Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => Ok(false),
744 Err(error) => Err(error),
745 }
746}
747
748struct Header {
750 writing_pass_started_at_ns: i64,
758 identity: SnapshotIdentity,
760 root: PathBuf,
762 entries: u64,
764}
765
766fn parse_header_fields(reader: &mut impl Read, engine: u64) -> ParseResult<Header> {
768 if read_u8(reader)? != path_encoding() {
769 return Err(ParseError::Invalid);
770 }
771 let writing_pass_started_at_ns = read_i64(reader)?;
772 let identity =
773 SnapshotIdentity::decode(engine, &read_array::<_, SNAPSHOT_IDENTITY_BYTES>(reader)?)
774 .ok_or(ParseError::Invalid)?;
775 let root = PathBuf::from(read_os_string(reader)?);
776 let entries = read_u64(reader)?;
777 if entries == 0 || entries > MAX_SNAPSHOT_ENTRIES {
778 return Err(ParseError::Invalid);
779 }
780 Ok(Header { writing_pass_started_at_ns, identity, root, entries })
781}
782
783fn parse_stream(
786 reader: &mut impl Read,
787 payload_len: u64,
788 types: std::sync::Arc<crate::classify::TypeRegistry>,
789 wanted: Option<SnapshotIdentity>,
790) -> ParseResult<LoadOutcome> {
791 if read_array::<_, 8>(reader)? != *MAGIC {
792 return Err(ParseError::Invalid);
793 }
794 let engine = engine_fingerprint();
795 if read_u32(reader)? != FORMAT_VERSION || read_u64(reader)? != engine {
796 return Err(ParseError::Invalid);
797 }
798 let Header { writing_pass_started_at_ns, identity, root: root_path, entries: count } =
799 parse_header_fields(reader, engine)?;
800 let serves = wanted.map_or(Serves::Exact, |wanted| serves_snapshot(identity, wanted));
801 let mut scope = match (serves, wanted) {
802 (Serves::ProjectControlsOff, Some(wanted)) => wanted.scan_scope(),
803 _ => identity.scan_scope(),
804 };
805 if scope.type_rules_fingerprint != types.fingerprint() {
806 if serves != Serves::Refuse {
807 return Err(ParseError::Invalid);
808 }
809 scope.type_rules_fingerprint = types.fingerprint();
820 }
821 let minimum_body = count
822 .checked_mul(u64::try_from(MIN_RECORD_BYTES).map_err(|_| ParseError::Invalid)?)
823 .ok_or(ParseError::Invalid)?;
824 if minimum_body > payload_len {
825 return Err(ParseError::Invalid);
826 }
827
828 let mut index = Index::new_with_scope_and_types(&root_path, scope, types);
829 index.set_writing_pass_started_at_ns(writing_pass_started_at_ns);
831 index.set_applying_source(Source::Cached, writing_pass_started_at_ns);
836 let mut ids: Vec<EntryId> = Vec::with_capacity(usize::try_from(count).unwrap_or(0));
840 let mut bytes_total = 0_u64;
845 let mut allocated_total = 0_u64;
846 for slot in 0..count {
847 let parent_slot = read_u32(reader)?;
848 let kind = EntryKind::from_u8(read_u8(reader)?).ok_or(ParseError::Invalid)?;
849 let name = read_os_string(reader)?;
850 let attrs = Attrs {
851 size: read_u64(reader)?,
852 allocated: read_u64(reader)?,
853 mtime_ns: read_i64(reader)?,
854 ctime_ns: read_i64(reader)?,
855 inode: read_u64(reader)?,
856 dev: read_u64(reader)?,
857 };
858
859 if parent_slot == NO_PARENT {
860 if slot != 0 || kind != EntryKind::Dir || !name.is_empty() {
861 return Err(ParseError::Invalid);
862 }
863 index
864 .apply_baseline(&Observation::new(vec![Op::Upsert {
865 path: PathBuf::new(),
866 kind,
867 attrs,
868 }]))
869 .map_err(|_| ParseError::Invalid)?;
870 ids.push(EntryId::ROOT);
871 continue;
872 }
873
874 let parent = *ids
875 .get(usize::try_from(parent_slot).map_err(|_| ParseError::Invalid)?)
876 .ok_or(ParseError::Invalid)?;
877 if !is_snapshot_name(&name) {
878 return Err(ParseError::Invalid);
879 }
880 if kind == EntryKind::File {
881 bytes_total = bytes_total.checked_add(attrs.size).ok_or(ParseError::Invalid)?;
882 allocated_total =
883 allocated_total.checked_add(attrs.allocated).ok_or(ParseError::Invalid)?;
884 }
885 let id = index.insert_loaded_child(parent, name, kind, attrs).ok_or(ParseError::Invalid)?;
891 ids.push(id);
892 }
893
894 let controls = read_controls(reader, identity.controls)?;
895 if serves == Serves::Exact {
896 index.install_controls(controls).map_err(|_| ParseError::Invalid)?;
897 }
898
899 let mut extra = [0u8; 1];
900 if reader.read(&mut extra).map_err(ParseError::Io)? != 0 {
901 return Err(ParseError::Invalid);
902 }
903 index.establish_baseline();
906 index.set_applying_source(Source::Revalidated, 0);
909 index.set_persistence_owed(false);
911 Ok(if serves == Serves::Refuse {
912 LoadOutcome::Refused { identity, root: root_path }
913 } else {
914 LoadOutcome::Served { index, stored: identity }
915 })
916}
917
918#[derive(Debug)]
919enum ParseError {
920 Invalid,
921 Io(std::io::Error),
922}
923
924type ParseResult<T> = std::result::Result<T, ParseError>;
925
926fn read_array<R: Read, const N: usize>(reader: &mut R) -> ParseResult<[u8; N]> {
927 let mut bytes = [0u8; N];
928 match reader.read_exact(&mut bytes) {
929 Ok(()) => Ok(bytes),
930 Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Err(ParseError::Invalid),
931 Err(error) => Err(ParseError::Io(error)),
932 }
933}
934
935fn read_u8(reader: &mut impl Read) -> ParseResult<u8> {
936 Ok(read_array::<_, 1>(reader)?[0])
937}
938
939fn read_u32(reader: &mut impl Read) -> ParseResult<u32> {
940 Ok(u32::from_le_bytes(read_array(reader)?))
941}
942
943fn read_u64(reader: &mut impl Read) -> ParseResult<u64> {
944 Ok(u64::from_le_bytes(read_array(reader)?))
945}
946
947fn read_i64(reader: &mut impl Read) -> ParseResult<i64> {
948 Ok(i64::from_le_bytes(read_array(reader)?))
949}
950
951fn read_bytes(reader: &mut impl Read) -> ParseResult<Vec<u8>> {
952 let len = read_u32(reader)?;
953 if len > MAX_PATH_BYTES {
954 return Err(ParseError::Invalid);
955 }
956 let mut bytes = vec![0u8; usize::try_from(len).map_err(|_| ParseError::Invalid)?];
957 match reader.read_exact(&mut bytes) {
958 Ok(()) => Ok(bytes),
959 Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Err(ParseError::Invalid),
960 Err(error) => Err(ParseError::Io(error)),
961 }
962}
963
964fn read_controls(
973 reader: &mut impl Read,
974 tier: ControlTierIdentity,
975) -> ParseResult<crate::control::ControlTable> {
976 let limits = match tier {
977 ControlTierIdentity::Observed { limits } => limits,
978 ControlTierIdentity::NotObserved => crate::control::ControlLimits::default(),
983 };
984 let control_count = read_u32(reader)?;
985 if control_count != 0 && !tier.is_observed() {
986 return Err(ParseError::Invalid);
987 }
988 if usize::try_from(control_count)
989 .map_err(|_| ParseError::Invalid)?
990 .saturating_mul(crate::control::CONTROL_SOURCE_OVERHEAD)
991 > SNAPSHOT_CONTROL_TABLE_CEILING
992 {
993 return Err(ParseError::Invalid);
994 }
995 let mut sources = Vec::new();
996 let mut source_bytes = 0_usize;
997 for _ in 0..control_count {
998 let path = PathBuf::from(read_os_string(reader)?);
999 let source = read_control_bytes(reader)?;
1000 source_bytes = source_bytes.saturating_add(source.len());
1001 if source_bytes > SNAPSHOT_CONTROL_TABLE_CEILING {
1002 return Err(ParseError::Invalid);
1003 }
1004 sources.push((path, source));
1005 }
1006 let mut controls = crate::control::ControlTable::with_limits(limits);
1007 for (path, source) in sources {
1008 let admission = controls.upsert(&path, source).map_err(|_| ParseError::Invalid)?;
1009 if admission != (crate::control::ControlAdmission::Retained { changed: true }) {
1010 return Err(ParseError::Invalid);
1011 }
1012 }
1013 if controls.retained_cost() > SNAPSHOT_CONTROL_TABLE_CEILING {
1014 return Err(ParseError::Invalid);
1015 }
1016 let refused = read_u32(reader)?;
1017 if refused != 0 && !tier.is_observed() {
1018 return Err(ParseError::Invalid);
1019 }
1020 for _ in 0..refused {
1021 let path = PathBuf::from(read_os_string(reader)?);
1022 let reason = match read_u8(reader)? {
1023 REFUSED_FOR_BUDGET => crate::control::ControlRefusalReason::Budget,
1024 REFUSED_FOR_LINE_LIMIT => crate::control::ControlRefusalReason::LineLimit,
1025 _ => return Err(ParseError::Invalid),
1026 };
1027 if !crate::control::is_control_file(&path)
1029 || controls.contains(&path)
1030 || limits.limit_for(reason).is_none()
1031 {
1032 return Err(ParseError::Invalid);
1033 }
1034 controls.record_refusal(&path, reason).map_err(|_| ParseError::Invalid)?;
1035 }
1036 Ok(controls)
1037}
1038
1039fn read_control_bytes(reader: &mut impl Read) -> ParseResult<Vec<u8>> {
1040 let len = read_u32(reader)?;
1041 if usize::try_from(len).map_err(|_| ParseError::Invalid)? > SNAPSHOT_CONTROL_TABLE_CEILING {
1042 return Err(ParseError::Invalid);
1043 }
1044 let mut bytes = vec![0u8; usize::try_from(len).map_err(|_| ParseError::Invalid)?];
1045 match reader.read_exact(&mut bytes) {
1046 Ok(()) => Ok(bytes),
1047 Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Err(ParseError::Invalid),
1048 Err(error) => Err(ParseError::Io(error)),
1049 }
1050}
1051
1052#[cfg(unix)]
1053fn read_os_string(reader: &mut impl Read) -> ParseResult<OsString> {
1054 Ok(os_string_from_bytes(&read_bytes(reader)?))
1055}
1056
1057#[cfg(not(unix))]
1058fn read_os_string(reader: &mut impl Read) -> ParseResult<OsString> {
1059 os_string_from_bytes(&read_bytes(reader)?).ok_or(ParseError::Invalid)
1060}
1061
1062fn put_bytes(buf: &mut Vec<u8>, bytes: &[u8]) -> Result<()> {
1063 let len = u32::try_from(bytes.len())
1064 .map_err(|_| Error::Snapshot("string too long for snapshot".into()))?;
1065 if len > MAX_PATH_BYTES {
1066 return Err(Error::Snapshot("path exceeds snapshot limit".into()));
1067 }
1068 buf.extend_from_slice(&len.to_le_bytes());
1069 buf.extend_from_slice(bytes);
1070 Ok(())
1071}
1072
1073fn put_controls(buf: &mut Vec<u8>, controls: &crate::control::ControlTable) -> Result<()> {
1076 if controls.retained_cost() > SNAPSHOT_CONTROL_TABLE_CEILING
1077 || controls.source_bytes() > SNAPSHOT_CONTROL_TABLE_CEILING
1078 {
1079 return Err(Error::Snapshot(format!(
1080 "the control table retains {} bytes of charge, above the {} bytes a snapshot can \
1081 carry; set a control budget below it, or open without a cache",
1082 controls.retained_cost(),
1083 SNAPSHOT_CONTROL_TABLE_CEILING
1084 )));
1085 }
1086 let sources: Vec<_> = controls.sources().collect();
1087 let control_count = u32::try_from(sources.len())
1088 .map_err(|_| Error::Snapshot("control table exceeds u32 capacity".into()))?;
1089 buf.extend_from_slice(&control_count.to_le_bytes());
1090 for (path, source) in sources {
1091 put_os_str(buf, path.as_os_str())?;
1092 let len = u32::try_from(source.len())
1093 .map_err(|_| Error::Snapshot("control source exceeds u32 capacity".into()))?;
1094 buf.extend_from_slice(&len.to_le_bytes());
1095 buf.extend_from_slice(source);
1096 }
1097 let refused = u32::try_from(controls.refused_len())
1098 .map_err(|_| Error::Snapshot("refused controls exceed u32 capacity".into()))?;
1099 buf.extend_from_slice(&refused.to_le_bytes());
1100 for refusal in controls.refusals() {
1101 put_os_str(buf, refusal.path.as_os_str())?;
1102 buf.push(match refusal.reason {
1103 crate::control::ControlRefusalReason::Budget => REFUSED_FOR_BUDGET,
1104 crate::control::ControlRefusalReason::LineLimit => REFUSED_FOR_LINE_LIMIT,
1105 });
1106 }
1107 Ok(())
1108}
1109
1110fn is_snapshot_name(name: &OsStr) -> bool {
1117 let mut components = Path::new(name).components();
1118 let Some(Component::Normal(component)) = components.next() else { return false };
1119 components.next().is_none() && component == name
1120}
1121
1122#[cfg(unix)]
1123pub(crate) fn path_encoding() -> u8 {
1124 PATH_ENCODING_UNIX_BYTES
1125}
1126
1127#[cfg(windows)]
1128pub(crate) fn path_encoding() -> u8 {
1129 PATH_ENCODING_WINDOWS_WIDE
1130}
1131
1132#[cfg(not(any(unix, windows)))]
1133pub(crate) fn path_encoding() -> u8 {
1134 PATH_ENCODING_UTF8
1135}
1136
1137#[cfg(unix)]
1138pub(crate) fn put_os_str(buf: &mut Vec<u8>, value: &OsStr) -> Result<()> {
1139 use std::os::unix::ffi::OsStrExt;
1140 put_bytes(buf, value.as_bytes())
1141}
1142
1143#[cfg(windows)]
1144pub(crate) fn put_os_str(buf: &mut Vec<u8>, value: &OsStr) -> Result<()> {
1145 use std::os::windows::ffi::OsStrExt;
1146 let mut bytes = Vec::new();
1147 for unit in value.encode_wide() {
1148 bytes.extend_from_slice(&unit.to_le_bytes());
1149 }
1150 put_bytes(buf, &bytes)
1151}
1152
1153#[cfg(not(any(unix, windows)))]
1154pub(crate) fn put_os_str(buf: &mut Vec<u8>, value: &OsStr) -> Result<()> {
1155 let text = value
1156 .to_str()
1157 .ok_or_else(|| Error::Snapshot("path is not valid UTF-8 on this platform".into()))?;
1158 put_bytes(buf, text.as_bytes())
1159}
1160
1161#[cfg(unix)]
1162fn os_string_from_bytes(bytes: &[u8]) -> OsString {
1163 use std::os::unix::ffi::OsStringExt;
1164 OsString::from_vec(bytes.to_vec())
1165}
1166
1167#[cfg(windows)]
1168fn os_string_from_bytes(bytes: &[u8]) -> Option<OsString> {
1169 use std::os::windows::ffi::OsStringExt;
1170 if bytes.len() % std::mem::size_of::<u16>() != 0 {
1172 return None;
1173 }
1174 let units: Vec<u16> = bytes
1175 .chunks_exact(std::mem::size_of::<u16>())
1176 .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
1177 .collect();
1178 Some(OsString::from_wide(&units))
1179}
1180
1181#[cfg(not(any(unix, windows)))]
1182fn os_string_from_bytes(bytes: &[u8]) -> Option<OsString> {
1183 Some(OsString::from(String::from_utf8(bytes.to_vec()).ok()?))
1184}
1185
1186pub(crate) fn write_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
1192 if keep_identical(path, bytes) {
1199 return Ok(());
1200 }
1201 replace_atomically(path, bytes)
1202}
1203
1204fn replace_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
1207 let parent = parent_dir(path);
1208 fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
1209 let (tmp, mut file) = create_temp_file(path, parent)?;
1210 let write_then_sync = file.write_all(bytes).and_then(|()| file.sync_all());
1211 if let Err(e) = write_then_sync {
1212 let _ = fs::remove_file(&tmp);
1213 return Err(Error::io(&tmp, e));
1214 }
1215 drop(file);
1216
1217 if let Err(e) = fs::rename(&tmp, path) {
1218 let _ = fs::remove_file(&tmp);
1219 return Err(Error::io(path, e));
1220 }
1221 reap_stale_temporaries(parent, path, STALE_TEMP_AGE);
1222 Ok(())
1223}
1224
1225fn keep_identical(path: &Path, bytes: &[u8]) -> bool {
1232 if !same_bytes_on_disk(path, bytes) {
1233 return false;
1234 }
1235 let _ = touch(path, std::time::SystemTime::now());
1236 true
1237}
1238
1239fn same_bytes_on_disk(path: &Path, bytes: &[u8]) -> bool {
1246 let Ok(mut file) = fs::File::open(path) else { return false };
1247 let Ok(metadata) = file.metadata() else { return false };
1248 if metadata.len() != u64::try_from(bytes.len()).unwrap_or(u64::MAX) {
1249 return false;
1250 }
1251 reads_as(&mut file, bytes) && matches!(file.read(&mut [0u8; 1]), Ok(0))
1253}
1254
1255fn reads_as(file: &mut fs::File, bytes: &[u8]) -> bool {
1260 let mut buffer = vec![0u8; bytes.len().min(1 << 20)];
1261 let mut offset = 0usize;
1262 while offset < bytes.len() {
1263 let want = buffer.len().min(bytes.len() - offset);
1264 let read = match file.read(&mut buffer[..want]) {
1265 Ok(0) | Err(_) => return false,
1266 Ok(read) => read,
1267 };
1268 let end = offset + read;
1269 if buffer[..read] != bytes[offset..end] {
1270 return false;
1271 }
1272 offset = end;
1273 }
1274 true
1275}
1276
1277fn touch(path: &Path, when: std::time::SystemTime) -> io::Result<()> {
1279 let file = OpenOptions::new().write(true).open(path)?;
1280 file.set_modified(when)
1281}
1282
1283fn reap_stale_temporaries(parent: &Path, path: &Path, older_than: std::time::Duration) {
1289 let Some(prefix) = temp_prefix(path) else { return };
1290 let Ok(entries) = fs::read_dir(parent) else { return };
1291 let now = std::time::SystemTime::now();
1292 for entry in entries.flatten() {
1293 let name = entry.file_name();
1294 if !name.as_encoded_bytes().starts_with(prefix.as_encoded_bytes()) {
1295 continue;
1296 }
1297 let stale = entry
1298 .metadata()
1299 .and_then(|meta| meta.modified())
1300 .ok()
1301 .and_then(|modified| now.duration_since(modified).ok())
1302 .is_some_and(|age| age >= older_than);
1303 if stale {
1304 let _ = fs::remove_file(entry.path());
1305 }
1306 }
1307}
1308
1309fn temp_prefix(path: &Path) -> Option<OsString> {
1315 let mut prefix = OsString::from(".");
1316 prefix.push(path.file_name()?);
1317 prefix.push(".tmp.");
1318 Some(prefix)
1319}
1320
1321fn parent_dir(path: &Path) -> &Path {
1328 match path.parent() {
1329 Some(parent) if !parent.as_os_str().is_empty() => parent,
1330 _ => Path::new("."),
1331 }
1332}
1333
1334fn temp_name(path: &Path, sequence: u64) -> OsString {
1340 let mut name = OsString::from(".");
1341 name.push(path.file_name().unwrap_or_else(|| OsStr::new("snapshot")));
1342 name.push(format!(".tmp.{}.{:016x}.{}", std::process::id(), *TEMP_FILE_ENTROPY, sequence));
1343 name
1344}
1345
1346fn create_temp_file(path: &Path, parent: &Path) -> Result<(PathBuf, fs::File)> {
1347 for _ in 0..MAX_TEMP_CREATE_ATTEMPTS {
1348 let sequence = NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed);
1349 let tmp = parent.join(temp_name(path, sequence));
1350 let mut options = OpenOptions::new();
1351 options.write(true).create_new(true);
1352 #[cfg(unix)]
1353 {
1354 use std::os::unix::fs::OpenOptionsExt;
1355 options.mode(0o600);
1356 }
1357 match options.open(&tmp) {
1358 Ok(file) => return Ok((tmp, file)),
1359 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
1360 Err(error) => return Err(Error::io(&tmp, error)),
1361 }
1362 }
1363 Err(Error::io(
1364 parent,
1365 std::io::Error::new(
1366 std::io::ErrorKind::AlreadyExists,
1367 "could not reserve a unique snapshot temporary file",
1368 ),
1369 ))
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374 use super::*;
1375 use crate::engine_contract::Observation;
1376 use crate::index::ExtTally;
1377
1378 fn attrs(size: u64, mtime_ns: i64) -> Attrs {
1379 Attrs {
1380 size,
1381 allocated: size.div_ceil(512) * 512,
1382 mtime_ns,
1383 ctime_ns: mtime_ns,
1384 inode: size.wrapping_mul(7).wrapping_add(1),
1385 dev: 3,
1386 }
1387 }
1388
1389 fn sample_index() -> Index {
1390 let mut index = Index::new("/some/root");
1391 index.apply_ok(&Observation::new(vec![
1392 Op::Upsert { path: PathBuf::from("src"), kind: EntryKind::Dir, attrs: attrs(0, 1) },
1393 Op::Upsert {
1394 path: PathBuf::from("src/main.rs"),
1395 kind: EntryKind::File,
1396 attrs: attrs(100, 10),
1397 },
1398 Op::Upsert {
1399 path: PathBuf::from("src/deep"),
1400 kind: EntryKind::Dir,
1401 attrs: attrs(0, 2),
1402 },
1403 Op::Upsert {
1404 path: PathBuf::from("src/deep/nested.rs"),
1405 kind: EntryKind::File,
1406 attrs: attrs(50, 20),
1407 },
1408 Op::Upsert {
1409 path: PathBuf::from("notes.md"),
1410 kind: EntryKind::File,
1411 attrs: attrs(7, 30),
1412 },
1413 ]));
1414 index
1415 }
1416
1417 fn entry_count_offset(bytes: &[u8]) -> usize {
1418 let root_len_at = ROOT_OFFSET;
1419 let root_len = u32::from_le_bytes(
1420 bytes[root_len_at..root_len_at + 4]
1421 .try_into()
1422 .expect("saved snapshot has a root length"),
1423 );
1424 root_len_at + 4 + usize::try_from(root_len).expect("root length fits usize")
1425 }
1426
1427 fn rewrite_checksum(bytes: &mut [u8]) {
1428 let payload_len = bytes.len() - CHECKSUM_BYTES - TRAILER.len();
1429 let checksum = crc32c(&bytes[..payload_len]);
1430 bytes[payload_len..payload_len + CHECKSUM_BYTES].copy_from_slice(&checksum.to_le_bytes());
1431 }
1432
1433 fn entry_record_fields(bytes: &[u8]) -> Vec<(std::ops::Range<usize>, std::ops::Range<usize>)> {
1435 let count_at = entry_count_offset(bytes);
1436 let count = u64::from_le_bytes(
1437 bytes[count_at..count_at + 8].try_into().expect("saved snapshot has an entry count"),
1438 );
1439 let mut at = count_at + 8;
1440 let mut fields = Vec::new();
1441 for _ in 0..count {
1442 let name_len_at = at + 5;
1443 let name_len = usize::try_from(u32::from_le_bytes(
1444 bytes[name_len_at..name_len_at + 4]
1445 .try_into()
1446 .expect("saved entry has a name length"),
1447 ))
1448 .expect("name length fits usize");
1449 let name_end = name_len_at + 4 + name_len;
1450 fields.push((name_len_at..name_end, name_end..name_end + 6 * 8));
1451 at = name_end + 6 * 8;
1452 }
1453 fields
1454 }
1455
1456 fn replace_entry_name(image: &[u8], record: usize, name: &OsStr) -> Vec<u8> {
1457 let mut encoded = Vec::new();
1458 put_os_str(&mut encoded, name).expect("encode replacement name");
1459 let field = entry_record_fields(image)[record].0.clone();
1460 let mut rewritten = image.to_vec();
1461 rewritten.splice(field, encoded);
1462 rewrite_checksum(&mut rewritten);
1463 rewritten
1464 }
1465
1466 #[test]
1467 fn crc32c_matches_the_standard_check_value() {
1468 assert_eq!(crc32c(b"123456789"), 0xe306_9283);
1469 }
1470
1471 #[test]
1472 fn crc32c_slicing_matches_the_byte_reference_on_uneven_lengths() {
1473 fn reference(bytes: &[u8]) -> u32 {
1477 let mut state = u32::MAX;
1478 for byte in bytes {
1479 let index = usize::from(state.to_le_bytes()[0] ^ *byte);
1480 state = CRC32C_TABLES[0][index] ^ (state >> 8);
1481 }
1482 !state
1483 }
1484 let mut data = Vec::new();
1485 let mut seed = 0x9e37_79b9u32;
1486 for length in [0usize, 1, 7, 8, 9, 15, 16, 63, 64, 65, 1000] {
1487 data.clear();
1488 for _ in 0..length {
1489 seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
1490 data.push(seed.to_le_bytes()[0]);
1491 }
1492 assert_eq!(crc32c(&data), reference(&data), "length {length}");
1493 }
1494 }
1495
1496 #[test]
1497 fn snapshot_names_must_equal_their_single_normal_component() {
1498 assert!(is_snapshot_name(OsStr::new("notes.md")));
1499 assert!(!is_snapshot_name(OsStr::new("notes.md/")));
1500 assert!(!is_snapshot_name(OsStr::new("a/b")));
1501 assert!(!is_snapshot_name(OsStr::new("../bad")));
1502 assert!(!is_snapshot_name(OsStr::new("")));
1503 assert!(!is_snapshot_name(OsStr::new(".")));
1504 assert!(!is_snapshot_name(OsStr::new("..")));
1505 #[cfg(unix)]
1506 {
1507 use std::os::unix::ffi::OsStringExt;
1508 let native = OsString::from_vec(vec![b'n', 0x80]);
1509 assert!(is_snapshot_name(&native), "canonical validation is OsStr identity");
1510 let aliased = OsString::from_vec(vec![b'n', 0x80, b'/']);
1511 assert!(!is_snapshot_name(&aliased));
1512 }
1513 }
1514
1515 #[test]
1516 fn a_valid_forged_rename_loads_and_is_found_by_lookup() {
1517 let dir = tempfile::tempdir().expect("tempdir");
1518 let path = dir.path().join("snapshot.fdu");
1519 let mut index = Index::new("/some/root");
1520 index.apply_ok(&Observation::new(vec![Op::Upsert {
1521 path: PathBuf::from("valid"),
1522 kind: EntryKind::File,
1523 attrs: attrs(1, 1),
1524 }]));
1525 save(&index, &path).expect("save");
1526 let saved = fs::read(&path).expect("read");
1527 let forged = replace_entry_name(&saved, 1, OsStr::new("renamed"));
1528 fs::write(&path, forged).expect("write valid rename");
1529 let restored = load(&path).expect("load").expect("a valid rename is a snapshot");
1530 assert!(restored.lookup(Path::new("renamed")).is_some());
1531 assert!(restored.lookup(Path::new("valid")).is_none());
1532 }
1533
1534 #[test]
1535 fn noncanonical_entry_names_are_rejected_after_integrity_checks() {
1536 let dir = tempfile::tempdir().expect("tempdir");
1537 let path = dir.path().join("snapshot.fdu");
1538 let mut index = Index::new("/some/root");
1539 index.apply_ok(&Observation::new(vec![Op::Upsert {
1540 path: PathBuf::from("valid"),
1541 kind: EntryKind::File,
1542 attrs: attrs(1, 1),
1543 }]));
1544 save(&index, &path).expect("save");
1545 let saved = fs::read(&path).expect("read");
1546
1547 #[cfg(windows)]
1548 let names = ["a/", "a//", "a/.", "./a", r"a\", r"a\\", r"a\."];
1549 #[cfg(not(windows))]
1550 let names = ["a/", "a//", "a/.", "./a"];
1551 for name in names {
1552 let forged = replace_entry_name(&saved, 1, OsStr::new(name));
1553 fs::write(&path, forged).expect("write forged snapshot");
1554 assert!(load(&path).expect("malformed snapshot is a miss").is_none(), "{name:?}");
1555 }
1556 }
1557
1558 #[test]
1559 fn a_checksummed_name_alias_cannot_serve_cache_only_content() {
1560 let dir = tempfile::tempdir().expect("tempdir");
1561 let root = dir.path().join("root");
1562 fs::create_dir(&root).expect("create root");
1563 fs::write(root.join("a"), b"one two\n").expect("write first");
1564 fs::write(root.join("bb"), b"one two\n").expect("write second");
1565 let snapshot_path = dir.path().join("snapshot.fdu");
1566 let config = crate::OpenFixture {
1567 cache_path: Some(snapshot_path.clone()),
1568 policy: crate::CachePolicy::Auto,
1569 analysis: crate::content::AnalysisRequest {
1570 profile: crate::content::AnalysisSet::NONE.with_lines(),
1571 ..crate::content::AnalysisRequest::default()
1572 },
1573 ..crate::OpenFixture::default()
1574 };
1575 crate::open_fixture(&root, &config).expect("seed snapshot and sidecar");
1576
1577 let mut image = fs::read(&snapshot_path).expect("read snapshot");
1578 let fields = entry_record_fields(&image);
1579 assert_eq!(fields.len(), 3, "root and two files");
1580 let first_attrs = image[fields[1].1.clone()].to_vec();
1581 image[fields[2].1.clone()].copy_from_slice(&first_attrs);
1582 let forged = replace_entry_name(&image, 2, OsStr::new("a/"));
1583 fs::write(&snapshot_path, forged).expect("write checksummed alias");
1584
1585 let only = crate::OpenFixture { stale_ok: true, ..config };
1586 assert!(
1587 matches!(crate::open_fixture(&root, &only), Err(Error::Snapshot(_))),
1588 "a malformed snapshot cannot shrink the cache-only completeness denominator"
1589 );
1590 }
1591
1592 #[test]
1593 fn a_loaded_index_reports_cached_provenance_not_fresh() {
1594 let dir = tempfile::tempdir().expect("tempdir");
1599 let path = dir.path().join("snapshot.fdu");
1600 let original = sample_index();
1601 save(&original, &path).expect("save");
1602
1603 let restored = load(&path).expect("load").expect("snapshot present");
1604 let provenance =
1605 restored.provenance(Path::new("src/main.rs")).expect("the loaded entry is present");
1606 assert_eq!(provenance.source, crate::Source::Cached);
1607 assert!(!provenance.is_verified(), "nothing has been stat'd since the load");
1608 assert!(
1609 provenance.observed_at_ns > 0,
1610 "a cached value must say as of when, or a UI cannot label it"
1611 );
1612
1613 assert_eq!(
1615 original.provenance(Path::new("src/main.rs")).expect("present").source,
1616 crate::Source::Scanned
1617 );
1618 }
1619
1620 #[test]
1621 fn a_loaded_root_reports_cached_provenance_not_fresh() {
1622 let dir = tempfile::tempdir().expect("tempdir");
1629 let path = dir.path().join("snapshot.fdu");
1630 save(&sample_index(), &path).expect("save");
1631
1632 let restored = load(&path).expect("load").expect("snapshot present");
1633 let provenance = restored.provenance(Path::new("")).expect("the root is always present");
1634 assert_eq!(provenance.source, crate::Source::Cached, "the root came off disk too");
1635 assert!(!provenance.is_verified(), "nothing has been stat'd since the load");
1636 assert!(
1637 provenance.observed_at_ns > 0,
1638 "a cached total must say as of when, or a UI cannot label it"
1639 );
1640 }
1641
1642 #[test]
1643 fn cached_observation_time_comes_from_the_header_after_touch_or_copy() {
1644 use std::time::{Duration, UNIX_EPOCH};
1645
1646 let dir = tempfile::tempdir().expect("tempdir");
1647 let path = dir.path().join("snapshot.fdu");
1648 let copied = dir.path().join("copied.fdu");
1649 let mut original = sample_index();
1650 original.set_writing_pass_started_at_ns(1_000);
1651 save(&original, &path).expect("save");
1652
1653 touch(&path, UNIX_EPOCH + Duration::from_secs(10)).expect("touch cache image");
1656 fs::copy(&path, &copied).expect("copy cache image");
1657 touch(&copied, UNIX_EPOCH + Duration::from_secs(20)).expect("touch copied image");
1658
1659 for cache in [&path, &copied] {
1660 let restored = load(cache).expect("load").expect("present");
1661 for entry in [Path::new(""), Path::new("src/main.rs")] {
1662 assert_eq!(
1663 restored.provenance(entry).expect("present").observed_at_ns,
1664 1_000,
1665 "{} uses its persisted pass start, not its cache-file mtime",
1666 cache.display()
1667 );
1668 }
1669 }
1670 }
1671
1672 #[test]
1673 fn revalidating_a_loaded_index_promotes_entries_out_of_cached() {
1674 let dir = tempfile::tempdir().expect("tempdir");
1679 let tree = dir.path().join("tree");
1680 std::fs::create_dir_all(tree.join("sub")).expect("create dirs");
1681 std::fs::write(tree.join("sub/file.txt"), b"contents").expect("write");
1682 let snapshot_path = dir.path().join("snapshot.fdu");
1683
1684 let config = crate::ScanConfig::default();
1685 let (original, report) = crate::scan::scan_into_index(&tree, &config).expect("scan");
1686 assert!(report.is_complete());
1687 save(&original, &snapshot_path).expect("save");
1688
1689 let mut restored = load(&snapshot_path).expect("load").expect("present");
1690 let target = Path::new("sub/file.txt");
1691 assert_eq!(
1692 restored.provenance(target).expect("present").source,
1693 crate::Source::Cached,
1694 "straight off disk, nothing has been checked"
1695 );
1696
1697 let reconciled =
1699 crate::scan::reconcile(&mut restored, &config, &mut |_| {}).expect("reconcile");
1700 assert!(reconciled.is_complete());
1701 assert_eq!(reconciled.apply.updated, 0, "the tree did not change");
1702
1703 let provenance = restored.provenance(target).expect("present");
1704 assert_eq!(
1705 provenance.source,
1706 crate::Source::Revalidated,
1707 "an unchanged entry that was freshly stat'd has still been verified"
1708 );
1709 assert!(provenance.is_verified());
1710 }
1711
1712 #[test]
1721 fn round_trip_preserves_every_entry_not_just_the_root() {
1722 let dir = tempfile::tempdir().expect("tempdir");
1723 let tree = dir.path().join("tree");
1724 let snapshot_path = dir.path().join("cache").join("snap.fdu");
1725 for (relative, contents) in [
1728 ("a.rs", &b"fn main() {}"[..]),
1729 ("deep/one/two/three/leaf.txt", b"leaf"),
1730 ("deep/one/two/sibling.rs", b"sibling"),
1731 ("deep/one/other.md", b"# other"),
1732 ("wide/w1.txt", b"1"),
1733 ("wide/w2.txt", b"22"),
1734 ("wide/w3.rs", b"333"),
1735 ("empty/.keep", b""),
1736 ] {
1737 let path = tree.join(relative);
1738 fs::create_dir_all(path.parent().expect("parent")).expect("create dirs");
1739 fs::write(&path, contents).expect("write");
1740 }
1741
1742 let config = crate::ScanConfig::default();
1743 let (original, report) = crate::scan::scan_into_index(&tree, &config).expect("scan");
1744 assert!(report.is_complete());
1745 save(&original, &snapshot_path).expect("save");
1746 let restored = load(&snapshot_path).expect("load").expect("present");
1747
1748 assert_eq!(restored.len(), original.len(), "entry count");
1749
1750 let mut stack = vec![crate::index::EntryId::ROOT];
1753 let mut compared = 0_u64;
1754 while let Some(id) = stack.pop() {
1755 let path = original.path_of(id).expect("original path");
1756 let mirrored = restored.lookup(&path).expect("restored entry at the same path");
1757 assert_eq!(
1758 original.kind_of(id),
1759 restored.kind_of(mirrored),
1760 "kind at {}",
1761 path.display()
1762 );
1763 assert_eq!(
1764 original.attrs_of(id),
1765 restored.attrs_of(mirrored),
1766 "attrs at {}",
1767 path.display()
1768 );
1769 if original.kind_of(id) == Some(crate::EntryKind::Dir) {
1772 let (before, after) = (
1773 original.rollup_of(id).expect("original rollup"),
1774 restored.rollup_of(mirrored).expect("restored rollup"),
1775 );
1776 assert_eq!(
1777 (
1778 before.files,
1779 before.dirs,
1780 before.bytes,
1781 before.allocated,
1782 before.newest_mtime_ns
1783 ),
1784 (after.files, after.dirs, after.bytes, after.allocated, after.newest_mtime_ns),
1785 "rollup at {}",
1786 path.display()
1787 );
1788 assert_eq!(before.by_ext, after.by_ext, "extension tallies at {}", path.display());
1789 let names: Vec<_> = original
1790 .children_of(id)
1791 .expect("original children")
1792 .map(|(name, _)| name.to_os_string())
1793 .collect();
1794 let mirrored_names: Vec<_> = restored
1795 .children_of(mirrored)
1796 .expect("restored children")
1797 .map(|(name, _)| name.to_os_string())
1798 .collect();
1799 assert_eq!(names, mirrored_names, "children of {}", path.display());
1800 stack.extend(original.children_of(id).expect("children").map(|(_, child)| child));
1801 }
1802 compared += 1;
1803 }
1804 assert_eq!(compared, original.len(), "every entry was compared");
1805
1806 assert_eq!(restored.clock(), crate::Clock::ZERO);
1808 assert!(restored.since(crate::Clock::ZERO).commits.is_empty());
1809 }
1810
1811 #[test]
1812 fn round_trip_preserves_tree_and_rollups() {
1813 let dir = tempfile::tempdir().expect("tempdir");
1814 let path = dir.path().join("cache").join("snap.fdu");
1815 let original = sample_index();
1816
1817 save(&original, &path).expect("save");
1818 let restored = load(&path).expect("load").expect("snapshot present");
1819
1820 assert_eq!(restored.root_path(), Path::new("/some/root"));
1821 assert_eq!(restored.clock(), crate::Clock::ZERO);
1822 assert!(restored.since(crate::Clock::ZERO).commits.is_empty());
1823 assert_eq!(restored.len(), original.len());
1824 let (restored_total, original_total) = (restored.total(), original.total());
1826 assert_eq!(
1827 (restored_total.files, restored_total.dirs, restored_total.bytes),
1828 (original_total.files, original_total.dirs, original_total.bytes)
1829 );
1830 assert_eq!(restored_total.allocated, original_total.allocated);
1831 assert_eq!(restored_total.newest_mtime_ns, original_total.newest_mtime_ns);
1832 assert_eq!(restored_total.by_ext, original_total.by_ext);
1833 assert!(!original.serving_indexes_enabled());
1834 assert!(!restored.serving_indexes_enabled());
1835 assert_eq!(restored.total().files, 3);
1836 assert_eq!(restored.total().dirs, 2);
1837 assert_eq!(restored.total().bytes, 157);
1838 assert_eq!(
1839 restored.total().by_ext[".rs"],
1840 ExtTally { files: 2, bytes: 150, allocated: 1024 }
1841 );
1842 assert_eq!(
1843 restored.attrs(Path::new("src/deep/nested.rs")),
1844 original.attrs(Path::new("src/deep/nested.rs"))
1845 );
1846 }
1847
1848 #[test]
1849 fn round_trip_preserves_exact_controls_and_fixed_partitions() {
1850 let dir = tempfile::tempdir().expect("tempdir");
1851 let path = dir.path().join("controls.fdu");
1852 let mut original =
1853 Index::new_with_scope("/some/root", crate::test_support::observing_controls());
1854 original.apply_ok(&Observation::new(vec![
1855 Op::Upsert {
1856 path: PathBuf::from(".gitignore"),
1857 kind: EntryKind::File,
1858 attrs: attrs(6, 1),
1859 },
1860 Op::Upsert {
1861 path: PathBuf::from("debug.log"),
1862 kind: EntryKind::File,
1863 attrs: attrs(10, 2),
1864 },
1865 Op::Upsert {
1866 path: PathBuf::from("keep.rs"),
1867 kind: EntryKind::File,
1868 attrs: attrs(20, 3),
1869 },
1870 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
1871 ]));
1872
1873 save(&original, &path).expect("save");
1874 let restored = load(&path).expect("load").expect("snapshot present");
1875
1876 assert!(
1877 restored
1878 .controls()
1879 .expect("control state observed")
1880 .source_is(Path::new(".gitignore"), b"*.log\n")
1881 );
1882 assert_eq!(restored.controls().expect("control state observed").source_bytes(), 6);
1883 assert_eq!(
1884 restored.is_ignored(Path::new("debug.log")).expect("control state observed"),
1885 Some(true)
1886 );
1887 assert_eq!(
1888 restored.is_ignored(Path::new("keep.rs")).expect("control state observed"),
1889 Some(false)
1890 );
1891 let partitions = restored.partition_total().expect("control state observed");
1892 assert_eq!(partitions, original.partition_total().expect("control state observed"));
1893 assert_eq!(partitions.all.files, 3);
1894 assert_eq!(partitions.unignored.files, 2);
1895 }
1896
1897 #[test]
1898 fn removing_the_last_control_before_save_round_trips_an_empty_table() {
1899 let dir = tempfile::tempdir().expect("tempdir");
1900 let path = dir.path().join("no-controls.fdu");
1901 let mut original =
1902 Index::new_with_scope("/some/root", crate::test_support::observing_controls());
1903 original.apply_ok(&Observation::new(vec![
1904 Op::Upsert {
1905 path: PathBuf::from(".gitignore"),
1906 kind: EntryKind::File,
1907 attrs: attrs(6, 1),
1908 },
1909 Op::Upsert {
1910 path: PathBuf::from("debug.log"),
1911 kind: EntryKind::File,
1912 attrs: attrs(10, 2),
1913 },
1914 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
1915 ]));
1916 original
1917 .apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from(".gitignore") }]));
1918
1919 save(&original, &path).expect("save");
1920 let restored = load(&path).expect("load").expect("snapshot present");
1921
1922 assert!(restored.controls().expect("control state observed").is_empty());
1923 assert_eq!(
1924 restored.is_ignored(Path::new("debug.log")).expect("control state observed"),
1925 Some(false)
1926 );
1927 let partitions = restored.partition_total().expect("control state observed");
1928 assert_eq!(partitions.all, partitions.unignored);
1929 }
1930
1931 #[test]
1932 fn a_control_table_at_its_shared_bound_round_trips() {
1933 let dir = tempfile::tempdir().expect("tempdir");
1934 let path = dir.path().join("bounded-controls.fdu");
1935 let mut original =
1936 Index::new_with_scope("/some/root", crate::test_support::observing_controls());
1937 let source = crate::control::source_at_test_limit();
1938 original.apply_ok(&Observation::new(vec![Op::ControlUpsert {
1939 path: PathBuf::from(".gitignore"),
1940 source: source.clone(),
1941 }]));
1942 assert_eq!(
1943 original.controls().expect("control state observed").retained_cost(),
1944 crate::control::DEFAULT_CONTROL_BUDGET
1945 );
1946
1947 save(&original, &path).expect("save at bound");
1948 let restored = load(&path).expect("load").expect("snapshot present");
1949
1950 assert_eq!(
1951 restored.controls().expect("control state observed").retained_cost(),
1952 original.controls().expect("control state observed").retained_cost()
1953 );
1954 assert!(
1955 restored
1956 .controls()
1957 .expect("control state observed")
1958 .source_is(Path::new(".gitignore"), &source)
1959 );
1960 }
1961
1962 #[test]
1967 fn control_limits_that_disagree_with_the_scope_are_refused_at_save_and_load() {
1968 let dir = tempfile::tempdir().expect("tempdir");
1969 let path = dir.path().join("limits.fdu");
1970 let lifted = crate::ScanConfig {
1971 control_limits: crate::control::ControlLimits {
1972 budget: None,
1973 ..crate::control::ControlLimits::default()
1974 },
1975 ..crate::ScanConfig::default()
1976 };
1977
1978 let mismatched = Index::new_with_scope("/some/root", lifted.scope());
1979 let error = save(&mismatched, &path).expect_err("the scope claims no budget");
1980 assert!(
1981 matches!(
1982 error,
1983 Error::ControlLimitsOutsideScope { limits }
1984 if limits == crate::control::ControlLimits::default()
1985 ),
1986 "{error}"
1987 );
1988 assert!(!path.exists(), "nothing is written");
1989
1990 let mut agreeing = Index::new_with_config("/some/root", &lifted);
1991 agreeing.apply_ok(&Observation::new(vec![Op::ControlUpsert {
1992 path: PathBuf::from(".gitignore"),
1993 source: b"*.log\n".to_vec(),
1994 }]));
1995 assert_eq!(agreeing.scope(), lifted.scope());
1996 save(&agreeing, &path).expect("save an index whose table enforces its scope's limits");
1997 let restored = load(&path).expect("load").expect("snapshot present");
1998 assert_eq!(restored.control_coverage(), agreeing.control_coverage());
1999 assert_eq!(restored.snapshot_identity(), lifted.snapshot_identity());
2000
2001 let saved = fs::read(&path).expect("read snapshot");
2006 let controls_at = IDENTITY_OFFSET + crate::stored_state::ENTRY_TIER_BYTES;
2007 let controls = controls_at..controls_at + crate::stored_state::CONTROL_TIER_BYTES;
2008 let blind = crate::ScanConfig { read_controls: false, ..lifted.clone() };
2009 let forge = |tier: ControlTierIdentity| {
2010 let mut forged = saved.clone();
2011 forged[controls.clone()].copy_from_slice(&tier.encode());
2012 rewrite_checksum(&mut forged);
2013 fs::write(&path, &forged).expect("write forged limits");
2014 (
2015 load(&path).expect("forged equals absent"),
2016 load_serving(&path, blind.types_shared(), blind.snapshot_identity())
2017 .expect("projected forged equals absent"),
2018 )
2019 };
2020 let tight = crate::control::ControlLimits { line_limit: Some(1), ..lifted.control_limits };
2021 let (exact, projected) = forge(ControlTierIdentity::Observed { limits: tight });
2022 assert!(exact.is_none());
2023 assert!(matches!(projected, LoadOutcome::Absent));
2024 let (exact, projected) = forge(ControlTierIdentity::NotObserved);
2025 assert!(exact.is_none());
2026 assert!(matches!(projected, LoadOutcome::Absent));
2027 let (exact, projected) = forge(lifted.control_identity());
2029 assert!(exact.is_some());
2030 let LoadOutcome::Served { index: projected, stored } = projected else {
2031 panic!("the valid control payload projects");
2032 };
2033 assert_eq!(serves_snapshot(stored, blind.snapshot_identity()), Serves::ProjectControlsOff);
2034 assert_eq!(projected.snapshot_identity(), blind.snapshot_identity());
2035 assert_eq!(projected.scope(), blind.scope());
2036 assert!(matches!(projected.controls(), Err(Error::ControlStateNotObserved)));
2037 }
2038
2039 fn index_with_refused_controls() -> Index {
2041 let mut index =
2042 Index::new_with_scope("/some/root", crate::test_support::observing_controls());
2043 let mut line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
2044 line.push(b'\n');
2045 index.apply_ok(&Observation::new(vec![
2046 Op::Upsert {
2047 path: PathBuf::from(".gitignore"),
2048 kind: EntryKind::File,
2049 attrs: attrs(6, 1),
2050 },
2051 Op::Upsert { path: PathBuf::from("a"), kind: EntryKind::Dir, attrs: attrs(0, 1) },
2052 Op::Upsert {
2053 path: PathBuf::from("a/.gitignore"),
2054 kind: EntryKind::File,
2055 attrs: attrs(1, 1),
2056 },
2057 Op::Upsert { path: PathBuf::from("b"), kind: EntryKind::Dir, attrs: attrs(0, 1) },
2058 Op::Upsert {
2059 path: PathBuf::from("b/.gitignore"),
2060 kind: EntryKind::File,
2061 attrs: attrs(1, 1),
2062 },
2063 Op::Upsert {
2064 path: PathBuf::from("b/debug.log"),
2065 kind: EntryKind::File,
2066 attrs: attrs(9, 1),
2067 },
2068 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
2069 Op::ControlUpsert { path: PathBuf::from("a/.gitignore"), source: line },
2070 Op::ControlUpsert {
2071 path: PathBuf::from("b/.gitignore"),
2072 source: b"y\n".repeat(crate::control::DEFAULT_CONTROL_BUDGET / 2),
2073 },
2074 ]));
2075 index
2076 }
2077
2078 #[test]
2081 fn a_snapshot_with_refused_controls_reloads_its_coverage_exactly() {
2082 let dir = tempfile::tempdir().expect("tempdir");
2083 let path = dir.path().join("refused.fdu");
2084 let original = index_with_refused_controls();
2085 let crate::control::ControlCoverage::Observed(coverage) = original.control_coverage()
2086 else {
2087 panic!("observed");
2088 };
2089 assert_eq!((coverage.applied, coverage.refused), (1, 2));
2090
2091 save(&original, &path).expect("save a partially covered index");
2092 let restored = load(&path).expect("load").expect("snapshot present");
2093
2094 assert_eq!(restored.control_coverage(), original.control_coverage());
2095 assert_eq!(
2096 restored.is_ignored(Path::new("b/debug.log")).expect("observed"),
2097 original.is_ignored(Path::new("b/debug.log")).expect("observed")
2098 );
2099 assert_eq!(
2100 restored.partition_total().expect("observed"),
2101 original.partition_total().expect("observed")
2102 );
2103 }
2104
2105 #[test]
2108 fn corrupt_refusal_records_fail_closed() {
2109 let dir = tempfile::tempdir().expect("tempdir");
2110 let path = dir.path().join("refused.fdu");
2111 save(&index_with_refused_controls(), &path).expect("save");
2112 let saved = fs::read(&path).expect("read snapshot");
2113 let footer = CHECKSUM_BYTES + TRAILER.len();
2114 let reason_at = saved.len() - footer - 1;
2116 assert_eq!(saved[reason_at], REFUSED_FOR_BUDGET);
2117
2118 let mut unknown_reason = saved;
2119 unknown_reason[reason_at] = 9;
2120 rewrite_checksum(&mut unknown_reason);
2121 fs::write(&path, &unknown_reason).expect("write corrupt reason");
2122 assert!(load(&path).expect("corrupt equals absent").is_none());
2123
2124 let defaults = crate::control::ControlLimits::default();
2129 let section = |refusals: &[(&str, u8)]| {
2130 let mut section = Vec::new();
2131 section.extend_from_slice(&1_u32.to_le_bytes());
2132 put_os_str(&mut section, OsStr::new(".gitignore")).expect("retained path");
2133 section.extend_from_slice(&6_u32.to_le_bytes());
2134 section.extend_from_slice(b"*.log\n");
2135 let count = u32::try_from(refusals.len()).expect("few refusals");
2136 section.extend_from_slice(&count.to_le_bytes());
2137 for (refusal, reason) in refusals {
2138 put_os_str(&mut section, OsStr::new(refusal)).expect("refused path");
2139 section.push(*reason);
2140 }
2141 section
2142 };
2143 let no_budget = crate::control::ControlLimits { budget: None, ..defaults };
2144 let no_line_limit = crate::control::ControlLimits { line_limit: None, ..defaults };
2145 for (limits, refusals) in [
2146 (defaults, &[("a/.gitignore", REFUSED_FOR_BUDGET)][..]),
2147 (no_budget, &[("a/.gitignore", REFUSED_FOR_LINE_LIMIT)][..]),
2148 (no_line_limit, &[("a/.gitignore", REFUSED_FOR_BUDGET)][..]),
2149 ] {
2150 let tier = ControlTierIdentity::Observed { limits };
2151 let valid = read_controls(&mut section(refusals).as_slice(), tier)
2152 .expect("a well-formed control section");
2153 assert_eq!((valid.len(), valid.refused_len()), (1, 1));
2154 }
2155 for (limits, refusals) in [
2156 (defaults, &[(".gitignore", REFUSED_FOR_BUDGET)][..]),
2157 (
2158 defaults,
2159 &[("a/.gitignore", REFUSED_FOR_BUDGET), ("a/.gitignore", REFUSED_FOR_BUDGET)][..],
2160 ),
2161 (no_budget, &[("a/.gitignore", REFUSED_FOR_BUDGET)][..]),
2162 (no_line_limit, &[("a/.gitignore", REFUSED_FOR_LINE_LIMIT)][..]),
2163 ] {
2164 let tier = ControlTierIdentity::Observed { limits };
2165 assert!(
2166 matches!(
2167 read_controls(&mut section(refusals).as_slice(), tier),
2168 Err(ParseError::Invalid)
2169 ),
2170 "{limits:?} {refusals:?}"
2171 );
2172 }
2173 for refusals in [&[][..], &[("a/.gitignore", REFUSED_FOR_BUDGET)][..]] {
2175 assert!(matches!(
2176 read_controls(&mut section(refusals).as_slice(), ControlTierIdentity::NotObserved),
2177 Err(ParseError::Invalid)
2178 ));
2179 }
2180 }
2181
2182 #[test]
2188 fn loading_a_snapshot_without_controls_skips_the_reclassification_walk() {
2189 fn visits_while_loading(path: &Path) -> (u64, Index) {
2190 crate::index::RECLASSIFY_VISITS.with(|visits| visits.set(0));
2191 let restored = load(path).expect("load").expect("snapshot present");
2192 (crate::index::RECLASSIFY_VISITS.with(std::cell::Cell::get), restored)
2193 }
2194
2195 let dir = tempfile::tempdir().expect("tempdir");
2196 let mut original =
2197 Index::new_with_scope("/some/root", crate::test_support::observing_controls());
2198 let mut ops = vec![Op::Upsert {
2199 path: PathBuf::from("src"),
2200 kind: EntryKind::Dir,
2201 attrs: attrs(0, 1),
2202 }];
2203 ops.extend((0..64).map(|sequence| Op::Upsert {
2204 path: PathBuf::from(format!("src/file-{sequence:02}.rs")),
2205 kind: EntryKind::File,
2206 attrs: attrs(sequence + 1, 2),
2207 }));
2208 original.apply_baseline_ok(&Observation::new(ops));
2209 let plain = dir.path().join("plain.fdu");
2210 save(&original, &plain).expect("save");
2211
2212 let (visits, restored) = visits_while_loading(&plain);
2213
2214 assert_eq!(visits, 0, "an empty control table has nothing to reclassify");
2215 assert!(restored.control_table().is_empty());
2216 assert_eq!(
2218 restored.is_ignored(Path::new("src/file-00.rs")).expect("control state observed"),
2219 Some(false)
2220 );
2221 assert_eq!(
2222 restored.partition_total().expect("control state observed"),
2223 original.partition_total().expect("control state observed")
2224 );
2225
2226 original.apply_ok(&Observation::new(vec![Op::ControlUpsert {
2228 path: PathBuf::from("src/.gitignore"),
2229 source: b"file-0*.rs\n".to_vec(),
2230 }]));
2231 let controlled = dir.path().join("controlled.fdu");
2232 save(&original, &controlled).expect("save");
2233
2234 let (visits, restored) = visits_while_loading(&controlled);
2235
2236 assert!(visits > 64, "{visits}");
2237 assert_eq!(
2238 restored.is_ignored(Path::new("src/file-00.rs")).expect("control state observed"),
2239 Some(true)
2240 );
2241 assert_eq!(
2242 restored.is_ignored(Path::new("src/file-10.rs")).expect("control state observed"),
2243 Some(false)
2244 );
2245 }
2246
2247 #[test]
2248 fn round_trip_handles_wide_directory_fanout() {
2249 const CHILDREN: u64 = 4_096;
2254 let dir = tempfile::tempdir().expect("tempdir");
2255 let path = dir.path().join("wide.fdu");
2256 let mut original = Index::new("/some/root");
2257 let ops = (0..CHILDREN)
2258 .map(|sequence| Op::Upsert {
2259 path: PathBuf::from(format!("child-{sequence:04}.dat")),
2260 kind: EntryKind::File,
2261 attrs: attrs(sequence + 1, i64::try_from(sequence).expect("fanout fits i64")),
2262 })
2263 .collect();
2264 original.apply_baseline_ok(&Observation::new(ops));
2265
2266 save(&original, &path).expect("save wide snapshot");
2267 let restored = load(&path).expect("load wide snapshot").expect("snapshot present");
2268
2269 assert_eq!(restored.total().files, CHILDREN);
2270 assert_eq!(restored.len(), original.len());
2271 assert_eq!(
2274 restored.attrs(Path::new("child-4095.dat")),
2275 original.attrs(Path::new("child-4095.dat"))
2276 );
2277 }
2278
2279 #[test]
2280 fn shared_save_captures_before_filesystem_io() {
2281 let dir = tempfile::tempdir().expect("tempdir");
2282 let path = dir.path().join("shared.fdu");
2283 let handle = IndexHandle::new(sample_index());
2284
2285 save_handle(&handle, &path).expect("save shared snapshot");
2286 handle
2287 .apply(&Observation::new(vec![Op::Upsert {
2288 path: PathBuf::from("after.txt"),
2289 kind: EntryKind::File,
2290 attrs: attrs(9, 40),
2291 }]))
2292 .expect("mutate after capture");
2293
2294 let restored = load(&path).expect("load").expect("snapshot present");
2295 assert!(restored.lookup(Path::new("after.txt")).is_none());
2296 assert!(handle.kind(Path::new("after.txt")).expect("query").is_some());
2297 }
2298
2299 #[test]
2300 fn missing_snapshot_is_absent_not_an_error() {
2301 let dir = tempfile::tempdir().expect("tempdir");
2302 let loaded = load(&dir.path().join("nope.fdu")).expect("load must not error");
2303 assert!(loaded.is_none());
2304 }
2305
2306 #[test]
2307 fn configured_size_limit_rejects_before_body_allocation() {
2308 let dir = tempfile::tempdir().expect("tempdir");
2309 let path = dir.path().join("snap.fdu");
2310 save(&sample_index(), &path).expect("save");
2311 let file_len = fs::metadata(&path).expect("metadata").len();
2312
2313 assert!(load_with_size_limit(&path, file_len - 1).expect("load must not error").is_none());
2314 }
2315
2316 #[test]
2317 fn foreign_file_is_treated_as_absent() {
2318 let dir = tempfile::tempdir().expect("tempdir");
2319 let path = dir.path().join("snap.fdu");
2320 fs::write(&path, b"this is not a snapshot at all").expect("write");
2321 assert!(load(&path).expect("load must not error").is_none());
2322 }
2323
2324 #[test]
2325 fn truncated_snapshot_is_treated_as_absent() {
2326 let dir = tempfile::tempdir().expect("tempdir");
2327 let path = dir.path().join("snap.fdu");
2328 save(&sample_index(), &path).expect("save");
2329
2330 let full = fs::read(&path).expect("read");
2331 for cut in [full.len() - 1, full.len() / 2, MAGIC.len() + 2] {
2332 fs::write(&path, &full[..cut]).expect("truncate");
2333 assert!(
2334 load(&path).expect("load must not error").is_none(),
2335 "a snapshot truncated to {cut} bytes must not parse"
2336 );
2337 }
2338 }
2339
2340 #[test]
2341 fn corrupt_body_with_intact_magic_and_trailer_is_rejected() {
2342 let dir = tempfile::tempdir().expect("tempdir");
2343 let path = dir.path().join("snap.fdu");
2344 save(&sample_index(), &path).expect("save");
2345
2346 let mut bytes = fs::read(&path).expect("read");
2347 let count_at = entry_count_offset(&bytes);
2349 bytes[count_at..count_at + 8].copy_from_slice(&u64::MAX.to_le_bytes());
2350 rewrite_checksum(&mut bytes);
2351 fs::write(&path, &bytes).expect("write");
2352
2353 assert!(load(&path).expect("load must not error").is_none());
2354 }
2355
2356 #[test]
2357 fn a_snapshot_whose_sizes_sum_past_u64_fails_closed() {
2358 let dir = tempfile::tempdir().expect("tempdir");
2362 let path = dir.path().join("snap.fdu");
2363 save(&sample_index(), &path).expect("save");
2364
2365 let mut bytes = fs::read(&path).expect("read");
2366 let mut files = 0;
2367 for (name, attrs) in entry_record_fields(&bytes) {
2368 let kind_at = name.start - 1;
2369 if EntryKind::from_u8(bytes[kind_at]) != Some(EntryKind::File) {
2370 continue;
2371 }
2372 bytes[attrs.start..attrs.start + 8].copy_from_slice(&u64::MAX.to_le_bytes());
2373 files += 1;
2374 if files == 2 {
2375 break;
2376 }
2377 }
2378 assert_eq!(files, 2, "the sample has two files to inflate");
2379 rewrite_checksum(&mut bytes);
2380 fs::write(&path, &bytes).expect("write");
2381
2382 assert!(load(&path).expect("load must not error").is_none());
2383 }
2384
2385 #[test]
2386 fn plausible_attribute_corruption_is_rejected() {
2387 let dir = tempfile::tempdir().expect("tempdir");
2388 let path = dir.path().join("snap.fdu");
2389 save(&sample_index(), &path).expect("save");
2390
2391 let mut bytes = fs::read(&path).expect("read");
2392 let records_at = entry_count_offset(&bytes) + 8;
2393 let root_size_at = records_at + 4 + 1 + 4;
2398 bytes[root_size_at] ^= 1;
2399 fs::write(&path, &bytes).expect("write");
2400
2401 assert!(load(&path).expect("load must not error").is_none());
2402 }
2403
2404 #[test]
2405 fn entry_names_with_path_components_are_rejected() {
2406 let dir = tempfile::tempdir().expect("tempdir");
2407 let path = dir.path().join("snap.fdu");
2408 save(&sample_index(), &path).expect("save");
2409
2410 let mut bytes = fs::read(&path).expect("read");
2411 let count_at = entry_count_offset(&bytes);
2412 let records_at = count_at + 8;
2413 let first_child_name_len_at = records_at + MIN_RECORD_BYTES + 4 + 1;
2414 let old_name_len = u32::from_le_bytes(
2415 bytes[first_child_name_len_at..first_child_name_len_at + 4]
2416 .try_into()
2417 .expect("saved snapshot has a child name length"),
2418 );
2419 let old_name_end = first_child_name_len_at
2420 + 4
2421 + usize::try_from(old_name_len).expect("name length fits usize");
2422 let mut invalid_name = Vec::new();
2423 put_os_str(&mut invalid_name, OsStr::new("../bad")).expect("encode invalid name");
2424 bytes.splice(first_child_name_len_at..old_name_end, invalid_name);
2425 rewrite_checksum(&mut bytes);
2426 fs::write(&path, &bytes).expect("write");
2427
2428 assert!(load(&path).expect("load must not error").is_none());
2429 }
2430
2431 #[test]
2432 fn oversized_declared_path_is_rejected_before_allocation() {
2433 let dir = tempfile::tempdir().expect("tempdir");
2434 let path = dir.path().join("snap.fdu");
2435 save(&sample_index(), &path).expect("save");
2436
2437 let mut bytes = fs::read(&path).expect("read");
2438 let root_len_at = ROOT_OFFSET;
2439 bytes[root_len_at..root_len_at + 4].copy_from_slice(&(MAX_PATH_BYTES + 1).to_le_bytes());
2440 rewrite_checksum(&mut bytes);
2441 fs::write(&path, &bytes).expect("write");
2442
2443 assert!(load(&path).expect("load must not error").is_none());
2444 }
2445
2446 #[test]
2447 fn engine_fingerprint_mismatch_discards_the_snapshot() {
2448 let dir = tempfile::tempdir().expect("tempdir");
2449 let path = dir.path().join("snap.fdu");
2450 save(&sample_index(), &path).expect("save");
2451
2452 let mut bytes = fs::read(&path).expect("read");
2453 let fp_at = MAGIC.len() + 4;
2454 bytes[fp_at] ^= 0xff;
2455 rewrite_checksum(&mut bytes);
2456 fs::write(&path, &bytes).expect("write");
2457
2458 assert!(load(&path).expect("load must not error").is_none());
2459 }
2460
2461 #[test]
2462 fn save_replaces_an_existing_snapshot_and_leaves_no_temp_files() {
2463 let dir = tempfile::tempdir().expect("tempdir");
2464 let path = dir.path().join("snap.fdu");
2465
2466 save(&sample_index(), &path).expect("first save");
2467 let mut smaller = Index::new("/some/root");
2468 smaller.apply_ok(&Observation::new(vec![Op::Upsert {
2469 path: PathBuf::from("only.txt"),
2470 kind: EntryKind::File,
2471 attrs: attrs(1, 1),
2472 }]));
2473 save(&smaller, &path).expect("second save");
2474
2475 let restored = load(&path).expect("load").expect("present");
2476 assert_eq!(restored.total().files, 1);
2477
2478 let leftovers: Vec<_> = fs::read_dir(dir.path())
2479 .expect("read_dir")
2480 .filter_map(std::result::Result::ok)
2481 .map(|e| e.file_name().to_string_lossy().into_owned())
2482 .filter(|name| name != "snap.fdu")
2483 .collect();
2484 assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}");
2485 }
2486
2487 #[test]
2488 fn an_abandoned_temporary_is_collected_once_it_is_old_enough() {
2489 let dir = tempfile::tempdir().expect("tempdir");
2500 let path = dir.path().join("snap.fdu");
2501 let prefix = temp_prefix(&path).expect("a named target has a prefix");
2502
2503 let mut corpse = prefix.clone();
2504 corpse.push("111.0123456789abcdef.7");
2505 let unrelated = OsString::from("notes.txt");
2506 fs::write(dir.path().join(&corpse), b"a writer killed long ago").expect("plant");
2507 fs::write(dir.path().join(&unrelated), b"not ours").expect("plant");
2508
2509 reap_stale_temporaries(dir.path(), &path, STALE_TEMP_AGE);
2511 assert!(dir.path().join(&corpse).exists(), "a fresh corpse must be left alone");
2512
2513 reap_stale_temporaries(dir.path(), &path, std::time::Duration::ZERO);
2515 assert!(!dir.path().join(&corpse).exists(), "an old corpse must be collected");
2516 assert!(dir.path().join(&unrelated).exists(), "unrelated files are never touched");
2517 }
2518
2519 #[test]
2520 fn the_reaper_only_matches_its_own_targets_temporaries() {
2521 let mine = temp_prefix(Path::new("/cache/snap.fdu")).expect("prefix");
2524 let theirs = temp_prefix(Path::new("/cache/other.fdu")).expect("prefix");
2525 assert_eq!(mine, OsString::from(".snap.fdu.tmp."));
2526 assert_ne!(mine, theirs);
2527 assert!(temp_prefix(Path::new("/")).is_none(), "a rootless path has no name");
2528 }
2529
2530 #[test]
2531 fn a_stale_temporary_does_not_block_a_later_write() {
2532 let dir = tempfile::tempdir().expect("tempdir");
2543 let path = dir.path().join("snap.fdu");
2544
2545 let first = NEXT_TEMP_FILE.load(Ordering::Relaxed);
2549 let planted: Vec<OsString> =
2550 (first..first + 32).map(|sequence| temp_name(&path, sequence)).collect();
2551 for name in &planted {
2552 fs::write(dir.path().join(name), b"corpse").expect("plant a stale temporary");
2553 }
2554
2555 write_atomically(&path, b"payload").expect("write past the stale temporaries");
2556 assert_eq!(fs::read(&path).expect("read back"), b"payload");
2557
2558 let mut survivors: Vec<_> = fs::read_dir(dir.path())
2561 .expect("read_dir")
2562 .filter_map(std::result::Result::ok)
2563 .map(|entry| entry.file_name())
2564 .filter(|name| name != "snap.fdu")
2565 .collect();
2566 survivors.sort();
2567 let mut expected = planted;
2568 expected.sort();
2569 assert_eq!(survivors, expected, "the write must step over corpses, not consume them");
2570 }
2571
2572 #[test]
2573 fn an_identical_payload_leaves_the_file_in_place() {
2574 let dir = tempfile::tempdir().expect("tempdir");
2578 let path = dir.path().join("snap.fdu");
2579 let payload: Vec<u8> =
2580 (0u32..(3 << 20)).map(|i| u8::try_from(i % 251).unwrap_or(0)).collect();
2581
2582 write_atomically(&path, &payload).expect("first write");
2583 let first = fs::metadata(&path).expect("metadata");
2584 let before = std::time::SystemTime::now();
2585 write_atomically(&path, &payload).expect("identical write");
2586 let second = fs::metadata(&path).expect("metadata");
2587
2588 assert_eq!(fs::read(&path).expect("read back"), payload);
2589 assert_same_file(&first, &second);
2590 assert!(second.modified().expect("mtime") >= before);
2593 let extras = fs::read_dir(dir.path())
2595 .expect("read_dir")
2596 .filter_map(std::result::Result::ok)
2597 .filter(|entry| entry.file_name() != "snap.fdu")
2598 .count();
2599 assert_eq!(extras, 0);
2600 }
2601
2602 #[test]
2603 fn a_different_payload_of_the_same_length_is_written() {
2604 let dir = tempfile::tempdir().expect("tempdir");
2607 let path = dir.path().join("snap.fdu");
2608 write_atomically(&path, b"payload-a").expect("first write");
2609 write_atomically(&path, b"payload-b").expect("second write");
2610 assert_eq!(fs::read(&path).expect("read back"), b"payload-b");
2611
2612 fs::write(&path, b"payload-b-and-more").expect("lengthen");
2614 assert!(!same_bytes_on_disk(&path, b"payload-b"));
2615 assert!(!same_bytes_on_disk(&path, b"payload-b-and-mor"));
2616 assert!(same_bytes_on_disk(&path, b"payload-b-and-more"));
2617 assert!(!same_bytes_on_disk(&dir.path().join("absent"), b""));
2618 }
2619
2620 #[cfg(unix)]
2621 fn assert_same_file(first: &fs::Metadata, second: &fs::Metadata) {
2622 use std::os::unix::fs::MetadataExt;
2623 assert_eq!(first.ino(), second.ino(), "the snapshot was replaced, not left in place");
2624 }
2625
2626 #[cfg(not(unix))]
2627 fn assert_same_file(first: &fs::Metadata, second: &fs::Metadata) {
2628 if let (Ok(a), Ok(b)) = (first.created(), second.created()) {
2631 assert_eq!(a, b, "the snapshot was replaced, not left in place");
2632 }
2633 }
2634
2635 #[test]
2636 fn a_bare_filename_target_resolves_to_an_openable_directory() {
2637 assert_eq!(parent_dir(Path::new("snap.fdu")), Path::new("."));
2642 assert!(fs::read_dir(parent_dir(Path::new("snap.fdu"))).is_ok(), "must be openable");
2643 assert_eq!(parent_dir(Path::new("/cache/snap.fdu")), Path::new("/cache"));
2644 assert_eq!(parent_dir(Path::new("cache/snap.fdu")), Path::new("cache"));
2645 }
2646
2647 #[test]
2648 fn a_temporary_name_is_unique_per_sequence_and_carries_process_entropy() {
2649 let path = Path::new("/cache/snap.fdu");
2652 assert_ne!(temp_name(path, 0), temp_name(path, 1), "the counter separates files");
2653 let name = temp_name(path, 0).to_string_lossy().into_owned();
2654 assert!(name.starts_with(".snap.fdu.tmp."), "reaper prefix must match: {name}");
2655 assert!(
2656 name.contains(&format!("{:016x}", *TEMP_FILE_ENTROPY)),
2657 "entropy must be in the name, or a recycled pid regenerates a corpse: {name}"
2658 );
2659 }
2660
2661 #[test]
2662 fn concurrent_atomic_writes_do_not_share_a_temporary_file() {
2663 use std::sync::{Arc, Barrier};
2664
2665 const WRITERS: usize = 8;
2666 const PAYLOAD_BYTES: usize = 1024 * 1024;
2667
2668 let dir = tempfile::tempdir().expect("tempdir");
2669 let path = dir.path().join("snap.fdu");
2670 let barrier = Arc::new(Barrier::new(WRITERS));
2671 let results = std::thread::scope(|scope| {
2672 let handles: Vec<_> = (0..WRITERS)
2673 .map(|writer| {
2674 let barrier = Arc::clone(&barrier);
2675 let path = path.clone();
2676 scope.spawn(move || {
2677 let byte = u8::try_from(writer + 1).expect("writer id fits");
2678 let bytes = vec![byte; PAYLOAD_BYTES];
2679 barrier.wait();
2680 write_atomically(&path, &bytes)
2681 })
2682 })
2683 .collect();
2684 handles.into_iter().map(std::thread::ScopedJoinHandle::join).collect::<Vec<_>>()
2685 });
2686
2687 for result in results {
2688 result.expect("writer thread did not panic").expect("concurrent atomic write");
2689 }
2690 let final_bytes = fs::read(&path).expect("read final image");
2691 assert_eq!(final_bytes.len(), PAYLOAD_BYTES);
2692 assert!(final_bytes.iter().all(|byte| *byte == final_bytes[0]));
2693
2694 let leftovers: Vec<_> = fs::read_dir(dir.path())
2695 .expect("read_dir")
2696 .filter_map(std::result::Result::ok)
2697 .map(|entry| entry.file_name())
2698 .filter(|name| name != "snap.fdu")
2699 .collect();
2700 assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}");
2701 }
2702
2703 #[test]
2704 fn concurrent_snapshot_reader_sees_only_a_complete_old_or_new_image() {
2705 use std::sync::{Arc, Barrier};
2706
2707 let dir = tempfile::tempdir().expect("tempdir");
2708 let path = dir.path().join("snap.fdu");
2709 let mut old_index = Index::new("/some/root");
2710 old_index.apply_ok(&Observation::new(vec![Op::Upsert {
2711 path: PathBuf::from("old.txt"),
2712 kind: EntryKind::File,
2713 attrs: attrs(11, 1),
2714 }]));
2715 let mut new_index = Index::new("/some/root");
2716 new_index.apply_ok(&Observation::new(vec![
2717 Op::Upsert {
2718 path: PathBuf::from("new-a.txt"),
2719 kind: EntryKind::File,
2720 attrs: attrs(20, 2),
2721 },
2722 Op::Upsert {
2723 path: PathBuf::from("new-b.txt"),
2724 kind: EntryKind::File,
2725 attrs: attrs(30, 3),
2726 },
2727 ]));
2728 save(&old_index, &path).expect("save old image");
2729
2730 let start: Arc<Barrier> = Arc::new(Barrier::new(2));
2731 let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
2732 std::thread::scope(|scope| {
2733 let writer_start: Arc<Barrier> = Arc::clone(&start);
2734 let writer_path: PathBuf = path.clone();
2735 scope.spawn(move || {
2736 writer_start.wait();
2737 done_tx.send(save(&new_index, &writer_path)).expect("report snapshot write");
2738 });
2739
2740 let reader_start: Arc<Barrier> = Arc::clone(&start);
2741 let reader_path: PathBuf = path.clone();
2742 scope.spawn(move || {
2743 reader_start.wait();
2744 let deadline: std::time::Instant =
2745 std::time::Instant::now() + std::time::Duration::from_secs(10);
2746 loop {
2747 assert!(
2748 std::time::Instant::now() < deadline,
2749 "snapshot replacement did not finish before the deadline"
2750 );
2751 let image: Index =
2752 load(&reader_path).expect("load during replacement").expect("image");
2753 match image.total().files {
2754 1 => {
2755 assert_eq!(image.total().bytes, 11);
2756 assert!(image.lookup(Path::new("old.txt")).is_some());
2757 assert!(image.lookup(Path::new("new-a.txt")).is_none());
2758 }
2759 2 => {
2760 assert_eq!(image.total().bytes, 50);
2761 assert!(image.lookup(Path::new("old.txt")).is_none());
2762 assert!(image.lookup(Path::new("new-a.txt")).is_some());
2763 assert!(image.lookup(Path::new("new-b.txt")).is_some());
2764 }
2765 partial => panic!("reader observed partial image with {partial} files"),
2766 }
2767
2768 match done_rx.try_recv() {
2769 Ok(result) => {
2770 result.expect("replace snapshot");
2771 break;
2772 }
2773 Err(std::sync::mpsc::TryRecvError::Empty) => {}
2774 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
2775 panic!("snapshot writer disconnected")
2776 }
2777 }
2778 }
2779 });
2780 });
2781
2782 let final_image: Index = load(&path).expect("load final").expect("final image");
2783 assert_eq!(final_image.total().files, 2);
2784 assert_eq!(final_image.total().bytes, 50);
2785 }
2786
2787 #[cfg(unix)]
2788 #[test]
2789 fn saved_snapshot_is_owner_readable_only() {
2790 use std::os::unix::fs::PermissionsExt;
2791
2792 let dir = tempfile::tempdir().expect("tempdir");
2793 let path = dir.path().join("snap.fdu");
2794 save(&sample_index(), &path).expect("save");
2795
2796 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
2797 assert_eq!(mode, 0o600);
2798 }
2799
2800 #[test]
2801 fn empty_index_round_trips() {
2802 let dir = tempfile::tempdir().expect("tempdir");
2803 let path = dir.path().join("snap.fdu");
2804 let empty = Index::new("/some/root");
2805
2806 save(&empty, &path).expect("save");
2807 let restored = load(&path).expect("load").expect("present");
2808 assert!(restored.is_empty());
2809 assert_eq!(restored.total(), empty.total());
2810 }
2811
2812 #[test]
2813 fn partial_index_is_never_persisted() {
2814 let dir = tempfile::tempdir().expect("tempdir");
2815 let path = dir.path().join("partial.fdu");
2816 save(&Index::new("/root"), &path).expect("complete baseline");
2817 let complete_bytes = fs::read(&path).expect("read complete snapshot");
2818
2819 let mut index = Index::new("/root");
2820 index.set_initial_freshness(false);
2821
2822 assert!(matches!(save(&index, &path), Err(Error::Snapshot(_))));
2823 assert_eq!(fs::read(&path).expect("old snapshot remains"), complete_bytes);
2824 }
2825
2826 #[test]
2827 fn semantic_scan_scope_round_trips() {
2828 let dir = tempfile::tempdir().expect("tempdir");
2829 let path = dir.path().join("snap.fdu");
2830 let entries = crate::EntryTierIdentity {
2831 engine: engine_fingerprint(),
2832 scope: crate::EntryScope {
2833 max_depth: Some(7),
2834 follow_symlinks: false,
2835 one_filesystem: true,
2836 hidden_fingerprint: 5,
2837 exclude_special: true,
2838 population: crate::query::IgnoredEntries::Include,
2839 control_fingerprint: 0,
2840 },
2841 type_rules_fingerprint: crate::classify::type_rule_fingerprint(),
2842 reducers_fingerprint: 33,
2843 };
2844 for controls in [
2847 ControlTierIdentity::Observed { limits: crate::control::ControlLimits::default() },
2848 ControlTierIdentity::NotObserved,
2849 ] {
2850 let identity = SnapshotIdentity { entries, controls };
2851 let index = Index::new_with_scope("/some/root", identity.scan_scope());
2852
2853 save(&index, &path).expect("save");
2854 let restored = load(&path).expect("load").expect("present");
2855 assert_eq!(restored.snapshot_identity(), identity);
2856 assert_eq!(restored.scope(), identity.scan_scope());
2857 let info = read_header(&path).expect("read header").expect("current");
2858 assert_eq!(info.identity, identity);
2859 assert_eq!(info.scope(), identity.scan_scope());
2860 }
2861 }
2862
2863 #[test]
2867 fn controls_on_and_off_snapshots_share_the_entry_identity() {
2868 let tree = tempfile::tempdir().expect("tree");
2869 fs::write(tree.path().join(".gitignore"), b"*.log\n").expect("write");
2870 fs::write(tree.path().join("debug.log"), b"log").expect("write");
2871 fs::create_dir(tree.path().join("src")).expect("mkdir");
2872 fs::write(tree.path().join("src/main.rs"), b"fn main() {}\n").expect("write");
2873 let dir = tempfile::tempdir().expect("tempdir");
2874 let observed = crate::ScanConfig::default();
2875 let blind = crate::ScanConfig { read_controls: false, ..observed.clone() };
2876
2877 let saved = [(&observed, "on.fdu"), (&blind, "off.fdu")].map(|(config, name)| {
2878 let (index, _) = crate::scan::scan_into_index(tree.path(), config).expect("scan");
2879 let path = dir.path().join(name);
2880 save(&index, &path).expect("save");
2881 let restored = load(&path).expect("load").expect("present");
2882 assert_eq!(restored.snapshot_identity(), config.snapshot_identity());
2883 (restored.snapshot_identity(), fs::read(&path).expect("read"))
2884 });
2885 let [(on, on_bytes), (off, off_bytes)] = saved;
2886
2887 assert_eq!(on.entries, off.entries);
2888 assert_ne!(on.controls, off.controls);
2889 let entry_tier = IDENTITY_OFFSET..IDENTITY_OFFSET + crate::stored_state::ENTRY_TIER_BYTES;
2890 let control_tier = entry_tier.end..ROOT_OFFSET;
2891 assert_eq!(on_bytes[entry_tier.clone()], off_bytes[entry_tier]);
2892 assert_ne!(on_bytes[control_tier.clone()], off_bytes[control_tier]);
2893
2894 let projected = load_serving(
2895 &dir.path().join("on.fdu"),
2896 blind.types_shared(),
2897 blind.snapshot_identity(),
2898 )
2899 .expect("load projection");
2900 let LoadOutcome::Served { index: projected, stored } = projected else {
2901 panic!("an observed snapshot should project to the blind request");
2902 };
2903 assert_eq!(serves_snapshot(stored, blind.snapshot_identity()), Serves::ProjectControlsOff);
2904 let (cold, _) = crate::scan::scan_into_index(tree.path(), &blind).expect("blind scan");
2905 assert_eq!(projected.snapshot_identity(), blind.snapshot_identity());
2906 assert_eq!(projected.scope(), blind.scope());
2907 assert_eq!(projected.len(), cold.len());
2908 assert_eq!(projected.total(), cold.total());
2909 assert!(matches!(projected.controls(), Err(Error::ControlStateNotObserved)));
2910 assert_eq!(
2911 projected.path_state(Path::new("debug.log")),
2912 cold.path_state(Path::new("debug.log"))
2913 );
2914
2915 let mut corrupt = on_bytes;
2916 corrupt[WRITING_PASS_STARTED_AT_OFFSET] ^= 1;
2917 fs::write(dir.path().join("on.fdu"), corrupt).expect("corrupt checksum");
2918 assert!(matches!(
2919 load_serving(
2920 &dir.path().join("on.fdu"),
2921 blind.types_shared(),
2922 blind.snapshot_identity(),
2923 )
2924 .expect("corrupt projection is absent"),
2925 LoadOutcome::Absent
2926 ));
2927 }
2928
2929 #[test]
2935 fn a_snapshot_under_other_ignore_rules_is_refused_for_the_default_population() {
2936 use crate::stored_state::IGNORE_RULES_VERSION;
2937
2938 let tree = tempfile::tempdir().expect("tree");
2939 fs::write(tree.path().join(".gitignore"), b"*.log\n").expect("write");
2940 fs::write(tree.path().join("debug.log"), b"log").expect("write");
2941 let config = crate::ScanConfig::default();
2942 let wanted = config.snapshot_identity();
2943 assert_eq!(wanted.entries.scope.population, crate::query::IgnoredEntries::Include);
2944 assert!(wanted.controls.is_observed(), "the default population reads .gitignore");
2945
2946 let (index, _) = crate::scan::scan_into_index(tree.path(), &config).expect("scan");
2947 let dir = tempfile::tempdir().expect("tempdir");
2948 let path = dir.path().join("snap.fdu");
2949 save(&index, &path).expect("save");
2950 let current = fs::read(&path).expect("read");
2951 let serve = || load_serving(&path, config.types_shared(), wanted).expect("load");
2952 assert!(matches!(serve(), LoadOutcome::Served { .. }), "this build's snapshot serves");
2953
2954 let engine_at = MAGIC.len() + 4;
2955 let tiers = IDENTITY_OFFSET..ROOT_OFFSET;
2956 for version in [IGNORE_RULES_VERSION - 1, IGNORE_RULES_VERSION + 1] {
2957 let label = format!("rules version {version}");
2958 let other = engine_fingerprint_under(version);
2959 assert_ne!(other, engine_fingerprint(), "{label}");
2960 let mut forged = current.clone();
2961 forged[engine_at..engine_at + 8].copy_from_slice(&other.to_le_bytes());
2962 rewrite_checksum(&mut forged);
2963 fs::write(&path, &forged).expect("write forged");
2964
2965 assert_eq!(forged[tiers.clone()], current[tiers.clone()], "{label}");
2968 let bytes = forged[tiers.clone()].try_into().expect("the tier identities");
2969 let stored = SnapshotIdentity::decode(wanted.entries.engine, bytes).expect("decode");
2970 assert_eq!(serves_snapshot(stored, wanted), Serves::Exact, "{label}");
2971
2972 assert!(matches!(serve(), LoadOutcome::Absent), "{label}");
2973 assert!(
2974 matches!(
2975 identify(&path).expect("identify"),
2976 Some(Identity::Stale(crate::cache::StaleReason::OtherEngine))
2977 ),
2978 "{label}"
2979 );
2980 assert_eq!(
2981 crate::cache::cache_status(&path).expect("status").state,
2982 crate::cache::CacheState::Stale(crate::cache::StaleReason::OtherEngine),
2983 "{label}"
2984 );
2985 }
2986 }
2987
2988 #[test]
2992 fn a_v4_snapshot_is_older_format() {
2993 const V4: u32 = 4;
2994 let dir = tempfile::tempdir().expect("tempdir");
2995 let path = dir.path().join("v4.fdu");
2996
2997 let mut image = MAGIC.to_vec();
2998 image.extend_from_slice(&V4.to_le_bytes());
2999 image.extend_from_slice(&0x4444_4444_4444_4444_u64.to_le_bytes());
3002 image.push(path_encoding());
3003 image.extend_from_slice(&u64::MAX.to_le_bytes());
3006 image.push(0);
3007 let scope = crate::ScanConfig::default().scope();
3008 for fingerprint in [
3009 scope.hidden_fingerprint,
3010 scope.ignore_rules_fingerprint,
3011 scope.type_rules_fingerprint,
3012 scope.reducers_fingerprint,
3013 ] {
3014 image.extend_from_slice(&fingerprint.to_le_bytes());
3015 }
3016 put_os_str(&mut image, OsStr::new("/some/root")).expect("root");
3017 image.extend_from_slice(&1_u64.to_le_bytes());
3018 image.extend_from_slice(&NO_PARENT.to_le_bytes());
3019 image.push(EntryKind::Dir as u8);
3020 put_os_str(&mut image, OsStr::new("")).expect("root name");
3021 image.extend_from_slice(&[0; 6 * 8]);
3022 image.extend_from_slice(&0_u32.to_le_bytes());
3024 for limit in [crate::DEFAULT_CONTROL_BUDGET, crate::DEFAULT_CONTROL_LINE_LIMIT] {
3025 image.push(1);
3026 image.extend_from_slice(&u64::try_from(limit).expect("limit").to_le_bytes());
3027 }
3028 image.extend_from_slice(&0_u32.to_le_bytes());
3029 let checksum = crc32c(&image);
3030 image.extend_from_slice(&checksum.to_le_bytes());
3031 image.extend_from_slice(TRAILER);
3032 fs::write(&path, &image).expect("write v4 image");
3033
3034 assert!(matches!(
3035 identify(&path).expect("identify"),
3036 Some(Identity::Stale(crate::cache::StaleReason::OlderFormat { version: V4 }))
3037 ));
3038 assert!(read_header(&path).expect("read header").is_none());
3039 assert!(load(&path).expect("load").is_none());
3040 assert_eq!(
3041 crate::cache::cache_status(&path).expect("status").state,
3042 crate::cache::CacheState::Stale(crate::cache::StaleReason::OlderFormat { version: V4 })
3043 );
3044 }
3045
3046 #[test]
3051 fn a_later_pass_over_the_same_facts_keeps_the_snapshot_in_place() {
3052 use std::time::{Duration, UNIX_EPOCH};
3053
3054 let dir = tempfile::tempdir().expect("tempdir");
3055 let path = dir.path().join("snap.fdu");
3056 let stamp_at = |bytes: &[u8]| {
3057 i64::from_le_bytes(
3058 bytes[WRITING_PASS_STARTED_AT_OFFSET..IDENTITY_OFFSET].try_into().expect("stamp"),
3059 )
3060 };
3061 let mtime = || fs::metadata(&path).expect("metadata").modified().expect("mtime");
3062 let nanos = |time: std::time::SystemTime| {
3063 i64::try_from(time.duration_since(UNIX_EPOCH).expect("after the epoch").as_nanos())
3064 .expect("nanoseconds")
3065 };
3066
3067 let mut first = sample_index();
3068 first.set_writing_pass_started_at_ns(1_000);
3069 save(&first, &path).expect("first save");
3070 let written = fs::metadata(&path).expect("metadata");
3071 assert_eq!(stamp_at(&fs::read(&path).expect("read")), 1_000);
3072 assert_eq!(
3073 load(&path).expect("load").expect("present").writing_pass_started_at_ns(),
3074 1_000
3075 );
3076
3077 let later_started = UNIX_EPOCH
3080 + Duration::from_secs(
3081 mtime().duration_since(UNIX_EPOCH).expect("after the epoch").as_secs() + 1,
3082 );
3083 let mut later = sample_index();
3084 later.set_writing_pass_started_at_ns(nanos(later_started));
3085 save(&later, &path).expect("unchanged save");
3086 let kept = fs::metadata(&path).expect("metadata");
3087 assert_same_file(&written, &kept);
3088 assert_eq!(kept.modified().expect("mtime"), later_started, "the kept image's as-of");
3089 assert_eq!(stamp_at(&fs::read(&path).expect("read")), 1_000);
3090 assert!(load(&path).expect("load").is_some(), "the kept image is still valid");
3091
3092 save(&first, &path).expect("older unchanged save");
3095 assert_same_file(&written, &fs::metadata(&path).expect("metadata"));
3096 assert_eq!(mtime(), later_started);
3097
3098 let mut corrupt = fs::read(&path).expect("read");
3101 let checksum_at = corrupt.len() - TRAILER.len() - CHECKSUM_BYTES;
3102 corrupt[checksum_at] ^= 0xff;
3103 fs::write(&path, &corrupt).expect("corrupt the checksum");
3104 assert!(load(&path).expect("load").is_none());
3105 save(&later, &path).expect("save over a corrupt image");
3106 assert_eq!(stamp_at(&fs::read(&path).expect("read")), nanos(later_started));
3107 assert!(load(&path).expect("load").is_some());
3108
3109 let mut changed = sample_index();
3110 changed.set_writing_pass_started_at_ns(3_000);
3111 changed.apply_ok(&Observation::new(vec![Op::Upsert {
3112 path: PathBuf::from("notes.md"),
3113 kind: EntryKind::File,
3114 attrs: attrs(8, 30),
3115 }]));
3116 save(&changed, &path).expect("changed save");
3117 let rewritten = fs::read(&path).expect("read");
3118 assert_eq!(stamp_at(&rewritten), 3_000);
3119 let restored = load(&path).expect("load").expect("present");
3120 assert_eq!(restored.writing_pass_started_at_ns(), 3_000);
3121 assert_eq!(restored.total(), changed.total());
3122 }
3123
3124 #[cfg(unix)]
3125 #[test]
3126 fn non_utf8_names_round_trip_without_aliasing() {
3127 use std::os::unix::ffi::OsStringExt;
3128
3129 let first = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
3130 let second = PathBuf::from(OsString::from_vec(vec![b'n', 0x81]));
3131 let mut root = PathBuf::from("/some");
3132 root.push(OsString::from_vec(vec![b'r', 0x82]));
3133 let mut index = Index::new(&root);
3134 index.apply_baseline_ok(&Observation::new(vec![
3135 Op::Upsert { path: first.clone(), kind: EntryKind::File, attrs: attrs(10, 1) },
3136 Op::Upsert { path: second.clone(), kind: EntryKind::File, attrs: attrs(20, 2) },
3137 ]));
3138
3139 let dir = tempfile::tempdir().expect("tempdir");
3140 let path = dir.path().join("snap.fdu");
3141 save(&index, &path).expect("save");
3142 let restored = load(&path).expect("load").expect("present");
3143
3144 assert_eq!(restored.root_path(), root);
3145 assert_eq!(restored.total().files, 2);
3146 assert_eq!(restored.total().bytes, 30);
3147 assert!(restored.lookup(&first).is_some());
3148 assert!(restored.lookup(&second).is_some());
3149 assert!(!restored.serving_indexes_enabled());
3150 }
3151}