1use super::{
4 ScanRuntimePlan, SourceVerificationWork, WatchChangeSet,
5 open_atlas_store_read_only_for_project, open_exact_fresh_atlas_store_for_project_controlled,
6 publication_input_error, source_changed_during_derivation, source_inspection_error,
7};
8use crate::CliError;
9use blake3::Hasher;
10use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
11use projectatlas_core::graph::ProjectInstanceId;
12use projectatlas_core::{IndexGeneration, IndexWorkControl, IndexWorkStage};
13use projectatlas_db::{AtlasStore, CapturedProjectBinding, IndexPublicationState};
14use std::collections::HashMap;
15use std::fmt;
16#[cfg(test)]
17use std::fs;
18use std::fs::File;
19use std::hash::Hash;
20use std::io::{Read, Take};
21use std::path::{Path, PathBuf};
22use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
23use std::sync::mpsc::{Receiver, SyncSender, TryRecvError, TrySendError, sync_channel};
24use std::sync::{Arc, Mutex};
25use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
26
27const SOURCE_OBSERVATION_CAPACITY: usize = 16;
29const SOURCE_OBSERVATION_QUEUE_CAPACITY: usize = 1_024;
31const VERIFIED_READ_ATTEMPTS: usize = 3;
33const MAX_POLICY_INPUT_BYTES: u64 = 16 * 1_024 * 1_024;
35
36#[derive(Clone, Debug, Eq, PartialEq)]
38pub(crate) struct VerifiedReadStamp {
39 pub(crate) process_nonce: [u8; 16],
41 pub(crate) epoch: u64,
43 pub(crate) generation: IndexGeneration,
45 pub(crate) project_instance_id: ProjectInstanceId,
47}
48
49#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
51pub(crate) struct VerifiedReadWork {
52 pub(crate) exact_verifications: u64,
54 pub(crate) filesystem_entries: u64,
56 pub(crate) filesystem_bytes: u64,
58 pub(crate) sqlite_read_statements: u64,
60 pub(crate) decoded_nodes: u64,
62 pub(crate) retries: u64,
64 pub(crate) elapsed: Duration,
66 pub(crate) output_bytes: u64,
68}
69
70impl VerifiedReadWork {
71 fn add_exact(&mut self, work: SourceVerificationWork) {
73 self.exact_verifications = self.exact_verifications.saturating_add(1);
74 self.filesystem_entries = self
75 .filesystem_entries
76 .saturating_add(work.filesystem_entries);
77 self.filesystem_bytes = self.filesystem_bytes.saturating_add(work.filesystem_bytes);
78 self.sqlite_read_statements = self
79 .sqlite_read_statements
80 .saturating_add(work.sqlite_read_statements);
81 self.decoded_nodes = self.decoded_nodes.saturating_add(work.decoded_nodes);
82 }
83}
84
85#[derive(Clone, Debug, Eq, PartialEq)]
87pub(crate) struct VerifiedReadOutcome<T> {
88 pub(crate) value: T,
90 pub(crate) stamp: VerifiedReadStamp,
92 pub(crate) work: VerifiedReadWork,
94}
95
96impl<T> VerifiedReadOutcome<T> {
97 pub(crate) fn with_output_bytes(mut self, output_bytes: usize) -> Self {
99 self.work.output_bytes = u64::try_from(output_bytes).unwrap_or(u64::MAX);
100 self
101 }
102}
103
104#[derive(Clone, Debug, Eq)]
106struct SourceBinding {
107 root: PathBuf,
109 database: PathBuf,
111 config: Option<PathBuf>,
113}
114
115impl PartialEq for SourceBinding {
116 fn eq(&self, other: &Self) -> bool {
117 self.root == other.root && self.database == other.database && self.config == other.config
118 }
119}
120
121impl Hash for SourceBinding {
122 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
123 self.root.hash(state);
124 self.database.hash(state);
125 self.config.hash(state);
126 }
127}
128
129impl SourceBinding {
130 fn new(database: &Path, root: &Path, config: Option<&Path>) -> Result<Self, CliError> {
132 let root = root.canonicalize().map_err(|source| CliError::Io {
133 path: root.to_path_buf(),
134 source,
135 })?;
136 let database = absolute_path_from(&root, database);
137 let config = config.map(|path| absolute_path_from(&root, path));
138 Ok(Self {
139 root,
140 database,
141 config,
142 })
143 }
144}
145
146#[derive(Clone, Debug, Eq, PartialEq)]
148struct VerifiedSourceEpoch {
149 stamp: VerifiedReadStamp,
151 ingress_sequence: u64,
153 contract_fingerprint: String,
155 policy_witness: String,
157}
158
159#[derive(Debug, Default)]
161struct ObservationState {
162 next_epoch: u64,
164 verified: Option<VerifiedSourceEpoch>,
166}
167
168struct SourceObservationEntry {
170 binding: SourceBinding,
172 _watcher: RecommendedWatcher,
174 receiver: Mutex<Receiver<Event>>,
176 ingress_sequence: Arc<AtomicU64>,
178 continuity_lost: Arc<AtomicBool>,
180 reconcile: Mutex<()>,
182 state: Mutex<ObservationState>,
184 #[cfg(test)]
185 test_sender: SyncSender<Event>,
187}
188
189impl fmt::Debug for SourceObservationEntry {
190 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
191 formatter
192 .debug_struct("SourceObservationEntry")
193 .field("binding", &self.binding)
194 .field(
195 "ingress_sequence",
196 &self.ingress_sequence.load(Ordering::Acquire),
197 )
198 .field(
199 "continuity_lost",
200 &self.continuity_lost.load(Ordering::Acquire),
201 )
202 .finish_non_exhaustive()
203 }
204}
205
206impl SourceObservationEntry {
207 fn start(binding: SourceBinding) -> Result<Self, CliError> {
209 let (sender, receiver) = sync_channel(SOURCE_OBSERVATION_QUEUE_CAPACITY);
210 #[cfg(test)]
211 let test_sender = sender.clone();
212 let ingress_sequence = Arc::new(AtomicU64::new(0));
213 let continuity_lost = Arc::new(AtomicBool::new(false));
214 let mut watcher = notify::recommended_watcher(watcher_callback(
215 sender,
216 Arc::clone(&ingress_sequence),
217 Arc::clone(&continuity_lost),
218 ))
219 .map_err(|source| observer_error(&binding.root, &source))?;
220 watcher
221 .watch(&binding.root, RecursiveMode::Recursive)
222 .map_err(|source| observer_error(&binding.root, &source))?;
223 if let Some(config) = binding.config.as_deref()
224 && !config.starts_with(&binding.root)
225 && let Some(parent) = config.parent()
226 {
227 watcher
228 .watch(parent, RecursiveMode::NonRecursive)
229 .map_err(|source| observer_error(&binding.root, &source))?;
230 }
231 Ok(Self {
232 binding,
233 _watcher: watcher,
234 receiver: Mutex::new(receiver),
235 ingress_sequence,
236 continuity_lost,
237 reconcile: Mutex::new(()),
238 state: Mutex::new(ObservationState::default()),
239 #[cfg(test)]
240 test_sender,
241 })
242 }
243
244 fn invalidate(&self) {
246 if let Ok(mut state) = self.state.lock() {
247 state.verified = None;
248 }
249 }
250
251 fn clear_before_exact_verification(&self) -> Result<(), CliError> {
253 self.invalidate();
254 self.continuity_lost.store(false, Ordering::Release);
255 let receiver = self
256 .receiver
257 .lock()
258 .map_err(|_poisoned| lock_error(&self.binding.root, "source observation receiver"))?;
259 loop {
260 match receiver.try_recv() {
261 Ok(_event) => {}
262 Err(TryRecvError::Empty) => return Ok(()),
263 Err(TryRecvError::Disconnected) => {
264 self.continuity_lost.store(true, Ordering::Release);
265 return Ok(());
266 }
267 }
268 }
269 }
270
271 fn changed_since_exact_verification(
273 &self,
274 scan_options: &projectatlas_fs::ScanOptions,
275 ) -> Result<bool, CliError> {
276 if self.continuity_lost.load(Ordering::Acquire) {
277 return Ok(true);
278 }
279 let receiver = self
280 .receiver
281 .lock()
282 .map_err(|_poisoned| lock_error(&self.binding.root, "source observation receiver"))?;
283 let mut changes = WatchChangeSet::default();
284 loop {
285 match receiver.try_recv() {
286 Ok(event) => {
287 let event_changes = observer_event_changes(&self.binding, scan_options, &event);
288 changes.requires_full_scan |= event_changes.requires_full_scan;
289 changes.paths.extend(event_changes.paths);
290 if self
291 .binding
292 .config
293 .as_ref()
294 .is_some_and(|config| event.paths.iter().any(|path| path == config))
295 {
296 changes.requires_full_scan = true;
297 }
298 }
299 Err(TryRecvError::Empty) => break,
300 Err(TryRecvError::Disconnected) => {
301 self.continuity_lost.store(true, Ordering::Release);
302 return Ok(true);
303 }
304 }
305 }
306 let changed = changes.requires_full_scan || !changes.paths.is_empty();
307 if !changed {
308 let acknowledged = self.ingress_sequence.load(Ordering::Acquire);
309 let mut state = self
310 .state
311 .lock()
312 .map_err(|_poisoned| lock_error(&self.binding.root, "source observation state"))?;
313 if let Some(epoch) = state.verified.as_mut() {
314 epoch.ingress_sequence = acknowledged;
315 }
316 }
317 Ok(changed)
318 }
319
320 fn install_epoch(
322 &self,
323 process_nonce: [u8; 16],
324 binding: &CapturedProjectBinding,
325 generation: IndexGeneration,
326 ingress_sequence: u64,
327 contract_fingerprint: String,
328 policy_witness: String,
329 ) -> Result<VerifiedSourceEpoch, CliError> {
330 let mut state = self
331 .state
332 .lock()
333 .map_err(|_poisoned| lock_error(&self.binding.root, "source observation state"))?;
334 state.next_epoch = state.next_epoch.saturating_add(1);
335 let epoch = VerifiedSourceEpoch {
336 stamp: VerifiedReadStamp {
337 process_nonce,
338 epoch: state.next_epoch,
339 generation,
340 project_instance_id: binding.project_instance_id,
341 },
342 ingress_sequence,
343 contract_fingerprint,
344 policy_witness,
345 };
346 state.verified = Some(epoch.clone());
347 Ok(epoch)
348 }
349
350 fn current_epoch(&self) -> Result<Option<VerifiedSourceEpoch>, CliError> {
352 self.state
353 .lock()
354 .map(|state| state.verified.clone())
355 .map_err(|_poisoned| lock_error(&self.binding.root, "source observation state"))
356 }
357}
358
359fn observer_event_changes(
361 binding: &SourceBinding,
362 scan_options: &projectatlas_fs::ScanOptions,
363 event: &Event,
364) -> WatchChangeSet {
365 let mut filtered = event.clone();
366 let metadata_directory = binding.root.join(".projectatlas");
367 filtered.paths.retain(|path| {
368 let candidate = super::absolute_watch_path(&binding.root, path);
369 !same_native_path(&candidate, &metadata_directory)
370 && !is_database_runtime_path(&candidate, &binding.database)
371 });
372 if filtered.paths.is_empty() && !event.need_rescan() {
373 return WatchChangeSet::default();
374 }
375 super::notify_event_changes(&binding.root, scan_options, &filtered)
376}
377
378fn is_database_runtime_path(candidate: &Path, database: &Path) -> bool {
380 if same_native_path(candidate, database) {
381 return true;
382 }
383 let Some(database_name) = database.file_name().and_then(|name| name.to_str()) else {
384 return false;
385 };
386 candidate.parent().is_some_and(|parent| {
387 database
388 .parent()
389 .is_some_and(|database_parent| same_native_path(parent, database_parent))
390 }) && candidate
391 .file_name()
392 .and_then(|name| name.to_str())
393 .is_some_and(|name| {
394 name.strip_prefix(database_name)
395 .is_some_and(|suffix| matches!(suffix, "-wal" | "-shm" | "-journal"))
396 })
397}
398
399fn same_native_path(left: &Path, right: &Path) -> bool {
401 let left = projectatlas_core::normalize_native_path_display(left);
402 let right = projectatlas_core::normalize_native_path_display(right);
403 if cfg!(windows) {
404 left.eq_ignore_ascii_case(&right)
405 } else {
406 left == right
407 }
408}
409
410pub(crate) struct SourceObservationRegistry {
412 process_nonce: [u8; 16],
414 entries: Mutex<HashMap<SourceBinding, Arc<SourceObservationEntry>>>,
416}
417
418impl fmt::Debug for SourceObservationRegistry {
419 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
420 let entries = self.entries.lock().map_or(0, |entries| entries.len());
421 formatter
422 .debug_struct("SourceObservationRegistry")
423 .field("entries", &entries)
424 .field("capacity", &SOURCE_OBSERVATION_CAPACITY)
425 .finish_non_exhaustive()
426 }
427}
428
429impl Default for SourceObservationRegistry {
430 fn default() -> Self {
431 Self {
432 process_nonce: process_nonce(),
433 entries: Mutex::new(HashMap::new()),
434 }
435 }
436}
437
438impl SourceObservationRegistry {
439 pub(crate) fn with_verified_read<T, F>(
441 &self,
442 database: &Path,
443 root: &Path,
444 config: Option<&Path>,
445 control: &IndexWorkControl,
446 query: F,
447 ) -> Result<VerifiedReadOutcome<T>, CliError>
448 where
449 F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<T, CliError>,
450 {
451 let binding = SourceBinding::new(database, root, config)?;
452 let Some(entry) = self.entry(binding.clone())? else {
453 return self.with_exact_fallback(&binding, control, query);
454 };
455 self.with_observed_read(&entry, control, query)
456 }
457
458 #[cfg(test)]
460 pub(crate) fn inject_test_event(
461 &self,
462 database: &Path,
463 root: &Path,
464 config: Option<&Path>,
465 event: Event,
466 ) -> Result<(), CliError> {
467 let binding = SourceBinding::new(database, root, config)?;
468 let entry = self
469 .entries
470 .lock()
471 .map_err(|_poisoned| lock_error(&binding.root, "source observation registry"))?
472 .get(&binding)
473 .cloned()
474 .ok_or_else(|| {
475 CliError::InvalidInput(format!(
476 "source observer test entry is missing for '{}'",
477 binding.root.display()
478 ))
479 })?;
480 entry.ingress_sequence.fetch_add(1, Ordering::AcqRel);
481 entry.test_sender.try_send(event).map_err(|source| {
482 CliError::InvalidInput(format!(
483 "deterministic source observer event injection failed: {source}"
484 ))
485 })
486 }
487
488 fn entry(
490 &self,
491 binding: SourceBinding,
492 ) -> Result<Option<Arc<SourceObservationEntry>>, CliError> {
493 let mut entries = self
494 .entries
495 .lock()
496 .map_err(|_poisoned| lock_error(&binding.root, "source observation registry"))?;
497 if let Some(entry) = entries.get(&binding) {
498 return Ok(Some(Arc::clone(entry)));
499 }
500 if entries.len() >= SOURCE_OBSERVATION_CAPACITY {
501 return Ok(None);
502 }
503 let entry = match SourceObservationEntry::start(binding.clone()) {
504 Ok(entry) => Arc::new(entry),
505 Err(_observer_unavailable) => return Ok(None),
506 };
507 entries.insert(binding, Arc::clone(&entry));
508 Ok(Some(entry))
509 }
510
511 fn with_observed_read<T, F>(
513 &self,
514 entry: &SourceObservationEntry,
515 control: &IndexWorkControl,
516 mut query: F,
517 ) -> Result<VerifiedReadOutcome<T>, CliError>
518 where
519 F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<T, CliError>,
520 {
521 let started = Instant::now();
522 let mut work = VerifiedReadWork::default();
523 for attempt in 0..VERIFIED_READ_ATTEMPTS {
524 if let Err(error) = control.check(IndexWorkStage::Publication) {
525 entry.invalidate();
526 return Err(error.into());
527 }
528 let (store, epoch) = match self.prepare_observed_store(entry, control, &mut work) {
529 Ok(prepared) => prepared,
530 Err(error) => {
531 entry.invalidate();
532 return Err(error);
533 }
534 };
535 let value = match query(&store, epoch.stamp.clone()) {
536 Ok(value) => value,
537 Err(error) => {
538 if matches!(error, CliError::IndexWork(_)) {
539 entry.invalidate();
540 }
541 drop(store.finish_index_read_snapshot());
542 return Err(error);
543 }
544 };
545 match Self::accepts_observed_result(entry, &epoch, control) {
546 Ok(true) => {
547 store.finish_index_read_snapshot()?;
548 work.elapsed = started.elapsed();
549 return Ok(VerifiedReadOutcome {
550 value,
551 stamp: epoch.stamp,
552 work,
553 });
554 }
555 Ok(false) => {}
556 Err(error) => {
557 entry.invalidate();
558 drop(store.finish_index_read_snapshot());
559 return Err(error);
560 }
561 }
562 entry.invalidate();
563 drop(store.finish_index_read_snapshot());
564 if attempt + 1 < VERIFIED_READ_ATTEMPTS {
565 work.retries = work.retries.saturating_add(1);
566 }
567 }
568 Err(source_changed_during_derivation(&entry.binding.root, "."))
569 }
570
571 fn prepare_observed_store(
573 &self,
574 entry: &SourceObservationEntry,
575 control: &IndexWorkControl,
576 work: &mut VerifiedReadWork,
577 ) -> Result<(AtlasStore, VerifiedSourceEpoch), CliError> {
578 let _reconcile = entry
579 .reconcile
580 .lock()
581 .map_err(|_poisoned| lock_error(&entry.binding.root, "source reconciliation"))?;
582 let plan = ScanRuntimePlan::for_path_controlled(
583 entry.binding.config.as_deref(),
584 &entry.binding.root,
585 None,
586 control,
587 )
588 .map_err(|source| publication_input_error(&entry.binding.root, source))?;
589 if entry.changed_since_exact_verification(&plan.scan_options)? {
590 entry.invalidate();
591 }
592 let contract_fingerprint = plan.publication_contract_fingerprint();
593 let policy_witness = source_policy_witness(&plan, control)?;
594 if let Some(epoch) = entry.current_epoch()?
595 && epoch.contract_fingerprint == contract_fingerprint
596 && epoch.policy_witness == policy_witness
597 && !entry.continuity_lost.load(Ordering::Acquire)
598 {
599 let store = open_atlas_store_read_only_for_project(
600 &entry.binding.database,
601 &entry.binding.root,
602 )?;
603 let publication = store.index_publication()?.filter(|publication| {
604 publication.state == IndexPublicationState::Complete
605 && publication.contract_fingerprint.as_deref()
606 == Some(contract_fingerprint.as_str())
607 });
608 let captured = store.captured_project_binding()?;
609 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
610 if publication
611 .is_some_and(|publication| publication.generation == epoch.stamp.generation)
612 && captured.project_instance_id == epoch.stamp.project_instance_id
613 {
614 return Ok((store, epoch));
615 }
616 entry.invalidate();
617 drop(store.finish_index_read_snapshot());
618 }
619
620 for _attempt in 0..VERIFIED_READ_ATTEMPTS {
621 entry.clear_before_exact_verification()?;
622 let before_plan = ScanRuntimePlan::for_path_controlled(
623 entry.binding.config.as_deref(),
624 &entry.binding.root,
625 None,
626 control,
627 )
628 .map_err(|source| publication_input_error(&entry.binding.root, source))?;
629 let before_contract = before_plan.publication_contract_fingerprint();
630 let before_policy = source_policy_witness(&before_plan, control)?;
631 let exact = open_exact_fresh_atlas_store_for_project_controlled(
632 &entry.binding.database,
633 &entry.binding.root,
634 entry.binding.config.as_deref(),
635 control,
636 )?;
637 work.add_exact(exact.work);
638 let after_plan = ScanRuntimePlan::for_path_controlled(
639 entry.binding.config.as_deref(),
640 &entry.binding.root,
641 None,
642 control,
643 )
644 .map_err(|source| publication_input_error(&entry.binding.root, source))?;
645 let after_contract = after_plan.publication_contract_fingerprint();
646 let after_policy = source_policy_witness(&after_plan, control)?;
647 let changed = entry.changed_since_exact_verification(&after_plan.scan_options)?;
648 if changed || before_contract != after_contract || before_policy != after_policy {
649 drop(exact.store.finish_index_read_snapshot());
650 continue;
651 }
652 let publication = exact
653 .store
654 .index_publication()?
655 .ok_or_else(|| source_changed_during_derivation(&entry.binding.root, "."))?;
656 let captured = exact.store.captured_project_binding()?;
657 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
658 let ingress_sequence = entry.ingress_sequence.load(Ordering::Acquire);
659 let epoch = entry.install_epoch(
660 self.process_nonce,
661 &captured,
662 publication.generation,
663 ingress_sequence,
664 after_contract,
665 after_policy,
666 )?;
667 return Ok((exact.store, epoch));
668 }
669 Err(source_changed_during_derivation(&entry.binding.root, "."))
670 }
671
672 fn accepts_observed_result(
674 entry: &SourceObservationEntry,
675 epoch: &VerifiedSourceEpoch,
676 control: &IndexWorkControl,
677 ) -> Result<bool, CliError> {
678 control.check(IndexWorkStage::Publication)?;
679 if entry.continuity_lost.load(Ordering::Acquire) {
680 return Ok(false);
681 }
682 let plan = ScanRuntimePlan::for_path_controlled(
683 entry.binding.config.as_deref(),
684 &entry.binding.root,
685 None,
686 control,
687 )
688 .map_err(|source| publication_input_error(&entry.binding.root, source))?;
689 if entry.changed_since_exact_verification(&plan.scan_options)?
690 || plan.publication_contract_fingerprint() != epoch.contract_fingerprint
691 || source_policy_witness(&plan, control)? != epoch.policy_witness
692 {
693 return Ok(false);
694 }
695 Ok(entry.current_epoch()?.is_some_and(|current| {
696 current.stamp == epoch.stamp
697 && current.contract_fingerprint == epoch.contract_fingerprint
698 && current.policy_witness == epoch.policy_witness
699 && current.ingress_sequence == entry.ingress_sequence.load(Ordering::Acquire)
700 }))
701 }
702
703 fn with_exact_fallback<T, F>(
705 &self,
706 binding: &SourceBinding,
707 control: &IndexWorkControl,
708 mut query: F,
709 ) -> Result<VerifiedReadOutcome<T>, CliError>
710 where
711 F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<T, CliError>,
712 {
713 let started = Instant::now();
714 let mut work = VerifiedReadWork::default();
715 for attempt in 0..VERIFIED_READ_ATTEMPTS {
716 let exact = open_exact_fresh_atlas_store_for_project_controlled(
717 &binding.database,
718 &binding.root,
719 binding.config.as_deref(),
720 control,
721 )?;
722 work.add_exact(exact.work);
723 let publication = exact
724 .store
725 .index_publication()?
726 .ok_or_else(|| source_changed_during_derivation(&binding.root, "."))?;
727 let captured = exact.store.captured_project_binding()?;
728 let stamp = VerifiedReadStamp {
729 process_nonce: self.process_nonce,
730 epoch: 0,
731 generation: publication.generation,
732 project_instance_id: captured.project_instance_id,
733 };
734 let value = query(&exact.store, stamp.clone())?;
735 exact.store.finish_index_read_snapshot()?;
736
737 let post = open_exact_fresh_atlas_store_for_project_controlled(
738 &binding.database,
739 &binding.root,
740 binding.config.as_deref(),
741 control,
742 )?;
743 work.add_exact(post.work);
744 let post_publication = post.store.index_publication()?;
745 let post_binding = post.store.captured_project_binding()?;
746 post.store.finish_index_read_snapshot()?;
747 if post_publication.is_some_and(|candidate| {
748 candidate.generation == stamp.generation
749 && post_binding.project_instance_id == stamp.project_instance_id
750 }) {
751 work.elapsed = started.elapsed();
752 return Ok(VerifiedReadOutcome { value, stamp, work });
753 }
754 if attempt + 1 < VERIFIED_READ_ATTEMPTS {
755 work.retries = work.retries.saturating_add(1);
756 }
757 }
758 Err(source_changed_during_derivation(&binding.root, "."))
759 }
760}
761
762fn watcher_callback(
764 sender: SyncSender<Event>,
765 ingress_sequence: Arc<AtomicU64>,
766 continuity_lost: Arc<AtomicBool>,
767) -> impl FnMut(notify::Result<Event>) + Send + 'static {
768 move |result| match result {
769 Ok(event) if matches!(event.kind, EventKind::Access(_)) => {}
770 Ok(event) => {
771 ingress_sequence.fetch_add(1, Ordering::AcqRel);
772 if event.need_rescan() {
773 continuity_lost.store(true, Ordering::Release);
774 }
775 match sender.try_send(event) {
776 Ok(()) => {}
777 Err(TrySendError::Full(_event) | TrySendError::Disconnected(_event)) => {
778 continuity_lost.store(true, Ordering::Release);
779 }
780 }
781 }
782 Err(_source) => {
783 ingress_sequence.fetch_add(1, Ordering::AcqRel);
784 continuity_lost.store(true, Ordering::Release);
785 }
786 }
787}
788
789fn source_policy_witness(
791 plan: &ScanRuntimePlan,
792 control: &IndexWorkControl,
793) -> Result<String, CliError> {
794 let mut hasher = Hasher::new();
795 hash_field(
796 &mut hasher,
797 "contract",
798 plan.publication_contract_fingerprint().as_bytes(),
799 );
800 hash_field(&mut hasher, "root", plan.root.to_string_lossy().as_bytes());
801 for path in source_policy_paths(plan, control)? {
802 control.check(IndexWorkStage::Publication)?;
803 hash_field(&mut hasher, "path", path.to_string_lossy().as_bytes());
804 match path.symlink_metadata() {
805 Ok(metadata) if metadata.is_file() => {
806 let file = File::open(&path).map_err(|source| CliError::Io {
807 path: path.clone(),
808 source,
809 })?;
810 hash_policy_file(&mut hasher, path.as_path(), file, control)?;
811 }
812 Ok(metadata) if metadata.is_dir() => {
813 hash_field(&mut hasher, "state", b"directory");
814 }
815 Ok(metadata) if metadata.file_type().is_symlink() => {
816 hash_field(&mut hasher, "state", b"symlink");
817 }
818 Ok(_metadata) => {
819 hash_field(&mut hasher, "state", b"other");
820 }
821 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
822 hash_field(&mut hasher, "state", b"absent");
823 }
824 Err(source) => {
825 return Err(CliError::Io { path, source });
826 }
827 }
828 }
829 Ok(hasher.finalize().to_hex().to_string())
830}
831
832fn source_policy_paths(
834 plan: &ScanRuntimePlan,
835 control: &IndexWorkControl,
836) -> Result<Vec<PathBuf>, CliError> {
837 let mut paths = projectatlas_fs::source_selection_policy_paths_controlled(&plan.root, control)
838 .map_err(|source| source_inspection_error(&plan.root, source))?;
839 if let Some(config) = plan.selected_config_path.as_ref() {
840 paths.push(config.clone());
841 } else {
842 paths.push(plan.root.join(".projectatlas").join("config.toml"));
843 paths.push(plan.root.join("projectatlas.toml"));
844 }
845 paths.sort();
846 paths.dedup();
847 Ok(paths)
848}
849
850fn hash_policy_file(
852 hasher: &mut Hasher,
853 path: &Path,
854 file: File,
855 control: &IndexWorkControl,
856) -> Result<(), CliError> {
857 let mut reader = file.take(MAX_POLICY_INPUT_BYTES.saturating_add(1));
858 let mut buffer = [0_u8; 8_192];
859 let mut observed = 0_u64;
860 loop {
861 control.check(IndexWorkStage::Publication)?;
862 let read = read_policy_chunk(&mut reader, &mut buffer, path)?;
863 if read == 0 {
864 break;
865 }
866 observed = observed.saturating_add(u64::try_from(read).unwrap_or(u64::MAX));
867 if observed > MAX_POLICY_INPUT_BYTES {
868 return Err(CliError::InvalidInput(format!(
869 "source-selection policy input '{}' exceeds the {} byte limit",
870 path.display(),
871 MAX_POLICY_INPUT_BYTES
872 )));
873 }
874 hasher.update(&buffer[..read]);
875 }
876 hash_field(hasher, "state", b"present");
877 Ok(())
878}
879
880fn read_policy_chunk(
882 reader: &mut Take<File>,
883 buffer: &mut [u8],
884 path: &Path,
885) -> Result<usize, CliError> {
886 reader.read(buffer).map_err(|source| CliError::Io {
887 path: path.to_path_buf(),
888 source,
889 })
890}
891
892fn hash_field(hasher: &mut Hasher, name: &str, value: &[u8]) {
894 hasher.update(name.as_bytes());
895 hasher.update(&[0]);
896 hasher.update(value);
897 hasher.update(&[0xff]);
898}
899
900fn absolute_path_from(root: &Path, path: &Path) -> PathBuf {
902 if path.is_absolute() {
903 path.to_path_buf()
904 } else {
905 root.join(path)
906 }
907}
908
909fn process_nonce() -> [u8; 16] {
911 let mut nonce = [0_u8; 16];
912 if getrandom::fill(&mut nonce).is_ok() {
913 return nonce;
914 }
915 let mut hasher = Hasher::new();
916 hasher.update(&std::process::id().to_le_bytes());
917 let time = SystemTime::now()
918 .duration_since(UNIX_EPOCH)
919 .map_or(0, |duration| duration.as_nanos());
920 hasher.update(&time.to_le_bytes());
921 nonce.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
922 nonce
923}
924
925fn observer_error(root: &Path, source: ¬ify::Error) -> CliError {
927 CliError::InvalidInput(format!(
928 "source observation is unavailable for '{}': {source}",
929 root.display()
930 ))
931}
932
933fn lock_error(root: &Path, owner: &str) -> CliError {
935 CliError::InvalidInput(format!(
936 "{owner} lock is unavailable for project root '{}'",
937 root.display()
938 ))
939}
940
941#[cfg(test)]
942mod tests {
943 use super::*;
944 use notify::event::{ModifyKind, RenameMode};
945 use notify::{EventKind, event::AccessKind};
946 use projectatlas_core::IndexCancellation;
947 use std::error::Error;
948
949 fn indexed_project(root: &Path) -> Result<(PathBuf, PathBuf), Box<dyn Error>> {
951 fs::create_dir_all(root.join(".projectatlas"))?;
952 let source = root.join("source.rs");
953 fs::write(&source, "fn original() {}\n")?;
954 let database = root.join(".projectatlas").join("projectatlas.db");
955 let mut store = super::super::open_atlas_store_for_project(&database, root)?;
956 let plan = ScanRuntimePlan::for_path(None, root, None)?;
957 super::super::run_scan_pipeline(
958 &mut store,
959 &plan,
960 &super::super::SymbolBuildOptions::new(
961 super::super::MAX_SYMBOL_FILE_BYTES,
962 Some(1),
963 None,
964 ),
965 )?;
966 drop(store);
967 Ok((database, source))
968 }
969
970 fn test_control() -> IndexWorkControl {
972 IndexWorkControl::new(IndexCancellation::new(), Some(Duration::from_secs(30)))
973 }
974
975 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
977 if condition {
978 Ok(())
979 } else {
980 Err(std::io::Error::other(message).into())
981 }
982 }
983
984 #[test]
985 fn watcher_callback_is_bounded_and_marks_overflow_and_rescan() {
986 let (sender, receiver) = sync_channel(1);
987 let sequence = Arc::new(AtomicU64::new(0));
988 let lost = Arc::new(AtomicBool::new(false));
989 let mut callback = watcher_callback(sender, Arc::clone(&sequence), Arc::clone(&lost));
990 callback(Ok(Event::new(EventKind::Modify(ModifyKind::Name(
991 RenameMode::Any,
992 )))));
993 callback(Ok(Event::new(EventKind::Modify(ModifyKind::Any))));
994
995 assert_eq!(sequence.load(Ordering::Acquire), 2);
996 assert!(lost.load(Ordering::Acquire));
997 assert!(receiver.try_recv().is_ok());
998 }
999
1000 #[test]
1001 fn watcher_callback_ignores_access_events_without_advancing_epoch() {
1002 let (sender, _receiver) = sync_channel(1);
1003 let sequence = Arc::new(AtomicU64::new(0));
1004 let lost = Arc::new(AtomicBool::new(false));
1005 let mut callback = watcher_callback(sender, Arc::clone(&sequence), Arc::clone(&lost));
1006 callback(Ok(Event::new(EventKind::Access(AccessKind::Any))));
1007
1008 assert_eq!(sequence.load(Ordering::Acquire), 0);
1009 assert!(!lost.load(Ordering::Acquire));
1010 }
1011
1012 #[test]
1013 fn verified_epoch_avoids_repeat_tree_and_node_table_work() -> Result<(), Box<dyn Error>> {
1014 let temp = tempfile::tempdir()?;
1015 let (database, _source) = indexed_project(temp.path())?;
1016 let registry = SourceObservationRegistry::default();
1017
1018 let first = registry.with_verified_read(
1019 &database,
1020 temp.path(),
1021 None,
1022 &test_control(),
1023 |store, stamp| Ok((store.overview()?, stamp)),
1024 )?;
1025 require(
1026 (1..=u64::try_from(VERIFIED_READ_ATTEMPTS)?).contains(&first.work.exact_verifications),
1027 "initial read did not perform a bounded exact verification",
1028 )?;
1029 require(
1030 first.work.filesystem_entries > 0,
1031 "initial read did not inspect filesystem entries",
1032 )?;
1033 require(
1034 first.work.decoded_nodes > 0,
1035 "initial read did not decode indexed nodes",
1036 )?;
1037
1038 let second = registry.with_verified_read(
1039 &database,
1040 temp.path(),
1041 None,
1042 &test_control(),
1043 |store, stamp| Ok((store.overview()?, stamp)),
1044 )?;
1045 require(
1046 second.work.exact_verifications == 0,
1047 "warm read unexpectedly repeated exact verification",
1048 )?;
1049 require(
1050 second.work.filesystem_entries == 0,
1051 "warm read unexpectedly inspected filesystem entries",
1052 )?;
1053 require(
1054 second.work.filesystem_bytes == 0,
1055 "warm read unexpectedly hashed source bytes",
1056 )?;
1057 require(
1058 second.work.decoded_nodes == 0,
1059 "warm read unexpectedly decoded the full node table",
1060 )?;
1061 require(
1062 second.work.sqlite_read_statements == 1,
1063 "warm freshness check used an unexpected SQLite statement count",
1064 )?;
1065 require(
1066 first.stamp == second.stamp,
1067 "warm read did not reuse the verified epoch",
1068 )?;
1069 Ok(())
1070 }
1071
1072 #[test]
1073 fn ignore_policy_changes_invalidate_the_epoch_and_refresh_source_truth()
1074 -> Result<(), Box<dyn Error>> {
1075 let temp = tempfile::tempdir()?;
1076 let (database, _source) = indexed_project(temp.path())?;
1077 let registry = SourceObservationRegistry::default();
1078 let first = registry.with_verified_read(
1079 &database,
1080 temp.path(),
1081 None,
1082 &test_control(),
1083 |store, _stamp| Ok(store.load_node_by_path("source.rs")?.is_some()),
1084 )?;
1085 require(first.value, "initial indexed source was missing")?;
1086
1087 fs::write(temp.path().join(".ignore"), "source.rs\n")?;
1088 let ignored = registry.with_verified_read(
1089 &database,
1090 temp.path(),
1091 None,
1092 &test_control(),
1093 |store, _stamp| Ok(store.load_node_by_path("source.rs")?.is_some()),
1094 );
1095
1096 require(
1097 matches!(ignored, Err(CliError::RefreshRequired(_))),
1098 "ignore policy change did not require refresh",
1099 )?;
1100 let binding = SourceBinding::new(&database, temp.path(), None)?;
1101 let entry = registry
1102 .entries
1103 .lock()
1104 .map_err(|_poisoned| std::io::Error::other("registry lock poisoned"))?
1105 .get(&binding)
1106 .cloned()
1107 .ok_or_else(|| std::io::Error::other("observer entry missing"))?;
1108 require(
1109 entry.current_epoch()?.is_none(),
1110 "ignore policy change left a verified epoch installed",
1111 )?;
1112 Ok(())
1113 }
1114
1115 #[test]
1116 fn git_exclude_changes_are_witnessed_with_a_git_directory() -> Result<(), Box<dyn Error>> {
1117 let temp = tempfile::tempdir()?;
1118 let (database, _source) = indexed_project(temp.path())?;
1119 let git_info = temp.path().join(".git").join("info");
1120 fs::create_dir_all(&git_info)?;
1121 fs::write(git_info.join("exclude"), "")?;
1122 let registry = SourceObservationRegistry::default();
1123 let first = registry.with_verified_read(
1124 &database,
1125 temp.path(),
1126 None,
1127 &test_control(),
1128 |store, _stamp| Ok(store.load_node_by_path("source.rs")?.is_some()),
1129 )?;
1130 require(first.value, "initial indexed source was missing")?;
1131
1132 fs::write(git_info.join("exclude"), "source.rs\n")?;
1133 let ignored = registry.with_verified_read(
1134 &database,
1135 temp.path(),
1136 None,
1137 &test_control(),
1138 |store, _stamp| Ok(store.load_node_by_path("source.rs")?.is_some()),
1139 );
1140
1141 require(
1142 matches!(ignored, Err(CliError::RefreshRequired(_))),
1143 "Git exclude change did not require refresh",
1144 )?;
1145 let binding = SourceBinding::new(&database, temp.path(), None)?;
1146 let entry = registry
1147 .entries
1148 .lock()
1149 .map_err(|_poisoned| std::io::Error::other("registry lock poisoned"))?
1150 .get(&binding)
1151 .cloned()
1152 .ok_or_else(|| std::io::Error::other("observer entry missing"))?;
1153 require(
1154 entry.current_epoch()?.is_none(),
1155 "Git exclude change left a verified epoch installed",
1156 )?;
1157 Ok(())
1158 }
1159
1160 #[test]
1161 fn mid_query_edit_discards_provisional_result_and_reconciles() -> Result<(), Box<dyn Error>> {
1162 let temp = tempfile::tempdir()?;
1163 let (database, source) = indexed_project(temp.path())?;
1164 let registry = SourceObservationRegistry::default();
1165 let _initial = registry.with_verified_read(
1166 &database,
1167 temp.path(),
1168 None,
1169 &test_control(),
1170 |store, _stamp| Ok(store.overview()?),
1171 )?;
1172 let binding = SourceBinding::new(&database, temp.path(), None)?;
1173 let entry = registry
1174 .entries
1175 .lock()
1176 .map_err(|_poisoned| std::io::Error::other("registry lock poisoned"))?
1177 .get(&binding)
1178 .cloned()
1179 .ok_or_else(|| std::io::Error::other("observer entry missing"))?;
1180 let mut calls = 0_u64;
1181 let revised = "fn revised() {}\n";
1182
1183 let outcome = registry.with_verified_read(
1184 &database,
1185 temp.path(),
1186 None,
1187 &test_control(),
1188 |store, _stamp| {
1189 calls = calls.saturating_add(1);
1190 let hash = store
1191 .load_node_by_path("source.rs")?
1192 .and_then(|node| node.node.content_hash)
1193 .ok_or_else(|| CliError::InvalidInput("source hash missing".to_string()))?;
1194 if calls == 1 {
1195 fs::write(&source, revised).map_err(|source_error| CliError::Io {
1196 path: source.clone(),
1197 source: source_error,
1198 })?;
1199 entry.ingress_sequence.fetch_add(1, Ordering::AcqRel);
1200 entry
1201 .test_sender
1202 .try_send(
1203 Event::new(EventKind::Modify(ModifyKind::Any)).add_path(source.clone()),
1204 )
1205 .map_err(|source_error| {
1206 CliError::InvalidInput(format!(
1207 "deterministic observation injection failed: {source_error}"
1208 ))
1209 })?;
1210 }
1211 Ok(hash)
1212 },
1213 )?;
1214
1215 require(calls >= 2, "mid-query edit did not retry the query")?;
1216 require(
1217 outcome.work.retries >= 1,
1218 "mid-query edit was not reported as a retry",
1219 )?;
1220 require(
1221 outcome.work.exact_verifications >= 1,
1222 "mid-query edit did not trigger exact verification",
1223 )?;
1224 require(
1225 outcome.value == blake3::hash(revised.as_bytes()).to_hex().to_string(),
1226 "accepted result did not reflect the revised source",
1227 )?;
1228 Ok(())
1229 }
1230
1231 #[test]
1232 fn cancellation_and_continuity_loss_never_certify_partial_truth() -> Result<(), Box<dyn Error>>
1233 {
1234 let temp = tempfile::tempdir()?;
1235 let (database, _source) = indexed_project(temp.path())?;
1236 let registry = SourceObservationRegistry::default();
1237 let initial = registry.with_verified_read(
1238 &database,
1239 temp.path(),
1240 None,
1241 &test_control(),
1242 |store, _stamp| Ok(store.overview()?),
1243 )?;
1244 require(
1245 initial.work.exact_verifications >= 1,
1246 "initial read did not establish exact truth",
1247 )?;
1248 let binding = SourceBinding::new(&database, temp.path(), None)?;
1249 let entry = registry
1250 .entries
1251 .lock()
1252 .map_err(|_poisoned| std::io::Error::other("registry lock poisoned"))?
1253 .get(&binding)
1254 .cloned()
1255 .ok_or_else(|| std::io::Error::other("observer entry missing"))?;
1256
1257 let cancellation = IndexCancellation::new();
1258 cancellation.cancel();
1259 let canceled_control = IndexWorkControl::new(cancellation, None);
1260 let canceled = registry.with_verified_read(
1261 &database,
1262 temp.path(),
1263 None,
1264 &canceled_control,
1265 |store, _stamp| Ok(store.overview()?),
1266 );
1267 require(
1268 matches!(canceled, Err(CliError::IndexWork(_))),
1269 "cancelled observer read did not return index-work cancellation",
1270 )?;
1271 require(
1272 entry.current_epoch()?.is_none(),
1273 "cancellation left the previous verified epoch installed",
1274 )?;
1275
1276 let recovered = registry.with_verified_read(
1277 &database,
1278 temp.path(),
1279 None,
1280 &test_control(),
1281 |store, _stamp| Ok(store.overview()?),
1282 )?;
1283 require(
1284 recovered.work.exact_verifications >= 1,
1285 "read after cancellation did not establish exact truth",
1286 )?;
1287 entry.continuity_lost.store(true, Ordering::Release);
1288 let reverified = registry.with_verified_read(
1289 &database,
1290 temp.path(),
1291 None,
1292 &test_control(),
1293 |store, _stamp| Ok(store.overview()?),
1294 )?;
1295 require(
1296 reverified.work.exact_verifications >= 1,
1297 "continuity loss did not trigger exact verification",
1298 )?;
1299 Ok(())
1300 }
1301}