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