1use crate::project_identity::{load_graph_generation, load_project_identity};
4use crate::repository_graph;
5use crate::schema::SCHEMA_VERSION;
6use crate::{
7 AtlasStore, DbError, DbResult, FileContentClassification, IndexPublicationState,
8 MAX_FILE_CONTENT_CLASSIFICATION_PATHS, load_index_publication,
9};
10use blake3::Hasher;
11use projectatlas_core::IndexGeneration;
12use projectatlas_core::graph::{
13 CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
14 CoverageState, DocumentTargetUnresolvedReason, EntityResolutionKey, EntitySelector,
15 ExtendedRelationKind, GraphEntity, GraphIdentityText, GraphLimitKind, GraphRelationKind,
16 LogicalRelation, LogicalRelationKey, PortableResolutionKey, ProjectInstanceId,
17 RelationDependencyKey, RelationOccurrence, RelationResolution, RepositoryFilePath, SourceSpan,
18};
19use projectatlas_core::language::ContentClassification;
20use rusqlite::Connection;
21use rusqlite::backup::Backup;
22use serde::{Deserialize, Serialize};
23use std::collections::{BTreeMap, BTreeSet};
24use std::num::NonZeroU32;
25use std::time::Duration;
26
27const DERIVED_SNAPSHOT_FORMAT_VERSION: u32 = 1;
29const DERIVED_SNAPSHOT_ROOT: &str = ".";
31pub const MAX_DERIVED_SNAPSHOT_JSON_BYTES: u64 = 512 * 1024 * 1024;
33const MAX_DERIVED_SNAPSHOT_ROWS: u64 = 1_000_000;
35const MAX_DERIVED_SNAPSHOT_RETAINED_BYTES: u64 = 256 * 1024 * 1024;
37const DERIVED_SNAPSHOT_DECODE_OBJECT_BYTES: u64 = 128;
39const DERIVED_SNAPSHOT_DECODE_HEADER_BYTES: u64 = 24;
41const DERIVED_SNAPSHOT_DECODE_PRIMITIVE_BYTES: u64 = 16;
43const MAX_DERIVED_SNAPSHOT_JSON_STRING_BYTES: usize = 256 * 1024;
45const MAX_PRIVATE_CAPTURE_BYTES: u64 = 16 * 1024 * 1024 * 1024;
47const MAX_SOURCE_STATE_ROWS: u64 = 5_000_000;
49const MAX_SOURCE_STATE_BYTES: u64 = 512 * 1024 * 1024;
51const BLAKE3_HEX_BYTES: usize = 64;
53
54#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
56pub struct DerivedGraphSnapshotMetadata {
57 pub runtime_version: String,
59 pub schema_version: i64,
61 pub root: String,
63 pub source_generation: IndexGeneration,
65 pub source_state_digest: String,
67 pub capability_fingerprint: String,
69}
70
71#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
73pub struct DerivedSnapshotContent {
74 pub table: String,
76 pub columns: Vec<String>,
78 pub rows: u64,
80}
81
82#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
84pub struct DerivedGraphSnapshot {
85 format_version: u32,
87 digest: String,
89 metadata: DerivedGraphSnapshotMetadata,
91 content: Vec<DerivedSnapshotContent>,
93 graph: PortableGraph,
95}
96
97impl DerivedGraphSnapshot {
98 #[must_use]
100 pub const fn metadata(&self) -> &DerivedGraphSnapshotMetadata {
101 &self.metadata
102 }
103
104 #[must_use]
106 pub fn content(&self) -> &[DerivedSnapshotContent] {
107 &self.content
108 }
109
110 #[must_use]
112 pub fn digest(&self) -> &str {
113 &self.digest
114 }
115
116 pub fn to_json(&self) -> DbResult<Vec<u8>> {
123 self.validate()?;
124 let encoded = serde_json::to_vec(self)?;
125 require_limit(
126 "encoded JSON bytes",
127 usize_to_u64(encoded.len())?,
128 MAX_DERIVED_SNAPSHOT_JSON_BYTES,
129 )?;
130 require_decode_budget(&encoded)?;
131 Ok(encoded)
132 }
133
134 pub fn from_json(encoded: &[u8]) -> DbResult<Self> {
141 require_limit(
142 "encoded JSON bytes",
143 usize_to_u64(encoded.len())?,
144 MAX_DERIVED_SNAPSHOT_JSON_BYTES,
145 )?;
146 require_decode_budget(encoded)?;
147 let snapshot = serde_json::from_slice::<Self>(encoded)?;
148 snapshot.validate()?;
149 Ok(snapshot)
150 }
151
152 fn validate(&self) -> DbResult<()> {
154 if self.format_version != DERIVED_SNAPSHOT_FORMAT_VERSION {
155 return invalid("unsupported portable format version");
156 }
157 if self.metadata.runtime_version != env!("CARGO_PKG_VERSION") {
158 return invalid("snapshot runtime version does not match this runtime");
159 }
160 if self.metadata.schema_version != SCHEMA_VERSION {
161 return invalid("snapshot schema version does not match this runtime");
162 }
163 if self.metadata.root != DERIVED_SNAPSHOT_ROOT {
164 return invalid("snapshot root is not the portable repository root");
165 }
166 if self.metadata.source_generation == IndexGeneration::ZERO {
167 return invalid("snapshot source generation is zero");
168 }
169 if !valid_digest(&self.metadata.source_state_digest) {
170 return invalid("snapshot source-state digest is malformed");
171 }
172 if self.metadata.capability_fingerprint.is_empty()
173 || self.metadata.capability_fingerprint.len() > 4_096
174 || self
175 .metadata
176 .capability_fingerprint
177 .chars()
178 .any(char::is_control)
179 {
180 return invalid("snapshot capability fingerprint is invalid");
181 }
182 self.graph.validate()?;
183 if self.content != expected_content(&self.graph)? {
184 return invalid("snapshot content inventory does not match the portable graph");
185 }
186 let digest = snapshot_digest(&self.metadata, &self.content, &self.graph)?;
187 if self.digest != digest {
188 return invalid("snapshot content digest does not match");
189 }
190 Ok(())
191 }
192}
193
194#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
196pub struct DerivedGraphSnapshotImport {
197 pub previous_generation: IndexGeneration,
199 pub published_generation: IndexGeneration,
201 pub digest: String,
203 pub content: Vec<DerivedSnapshotContent>,
205}
206
207pub(crate) struct CapturedGraph {
209 pub(crate) file_classifications: Vec<FileContentClassification>,
211 pub(crate) entities: Vec<GraphEntity>,
213 pub(crate) relations: Vec<LogicalRelation>,
215 pub(crate) document_unresolved_reasons: Vec<([u8; 32], DocumentTargetUnresolvedReason)>,
217 pub(crate) occurrences: Vec<RelationOccurrence>,
219 pub(crate) coverage: Vec<CoverageRecord>,
221 pub(crate) entity_exports: Vec<([u8; 32], CanonicalResolutionKey)>,
223 pub(crate) relation_dependencies: Vec<([u8; 32], CanonicalResolutionKey)>,
225}
226
227pub(crate) struct SnapshotBudget {
229 rows: u64,
231 retained_bytes: u64,
233}
234
235impl SnapshotBudget {
236 pub(crate) const fn new() -> Self {
238 Self {
239 rows: 0,
240 retained_bytes: 0,
241 }
242 }
243
244 pub(crate) fn admit(&mut self, bytes: u64) -> DbResult<()> {
246 self.rows = self
247 .rows
248 .checked_add(1)
249 .ok_or(DbError::DerivedSnapshotInvalid {
250 reason: "snapshot row count overflowed",
251 })?;
252 require_limit("decoded rows", self.rows, MAX_DERIVED_SNAPSHOT_ROWS)?;
253 self.retained_bytes = self
254 .retained_bytes
255 .checked_add(bytes)
256 .and_then(|value| value.checked_add(128))
257 .ok_or(DbError::DerivedSnapshotInvalid {
258 reason: "snapshot retained byte count overflowed",
259 })?;
260 require_limit(
261 "decoded retained bytes",
262 self.retained_bytes,
263 MAX_DERIVED_SNAPSHOT_RETAINED_BYTES,
264 )
265 }
266}
267
268impl AtlasStore {
269 pub fn export_derived_graph_snapshot(&self) -> DbResult<DerivedGraphSnapshot> {
276 let capture_dir = tempfile::tempdir().map_err(|source| DbError::DerivedSnapshotIo {
277 path: std::env::temp_dir(),
278 source,
279 })?;
280 let capture_path = capture_dir.path().join("derived-graph-capture.sqlite");
281 let mut capture = Connection::open(&capture_path).map_err(DbError::from)?;
282 require_private_capture_size(&self.connection)?;
283 {
284 let backup = Backup::new(&self.connection, &mut capture)?;
285 backup.run_to_completion(256, Duration::from_millis(1), None)?;
286 }
287 snapshot_from_stable_capture(&capture)
288 }
289
290 pub(crate) fn export_derived_graph_snapshot_from_stable_copy(
292 &self,
293 ) -> DbResult<DerivedGraphSnapshot> {
294 snapshot_from_stable_capture(&self.connection)
295 }
296
297 pub(crate) fn import_worktree_hydration_snapshot(
299 &mut self,
300 snapshot: &DerivedGraphSnapshot,
301 ) -> DbResult<DerivedGraphSnapshotImport> {
302 snapshot.validate()?;
303 if self.index_publication()?.is_some()
304 || load_graph_generation(&self.connection)? != Some(IndexGeneration::ZERO)
305 {
306 return invalid("worktree hydration destination is already published");
307 }
308 if source_state_digest(&self.connection)? != snapshot.metadata.source_state_digest {
309 return invalid("worktree hydration source state changed before rebinding");
310 }
311 let project = self
312 .project_instance_id()?
313 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
314 self.publish_snapshot(snapshot, project, IndexGeneration::ZERO, false, || Ok(()))
315 }
316
317 fn publish_snapshot(
319 &mut self,
320 snapshot: &DerivedGraphSnapshot,
321 project: ProjectInstanceId,
322 base_generation: IndexGeneration,
323 projection: bool,
324 before_publication: impl FnOnce() -> DbResult<()>,
325 ) -> DbResult<DerivedGraphSnapshotImport> {
326 let next_generation = base_generation
327 .checked_next()
328 .ok_or(DbError::PublicationGenerationOverflow)?;
329 let graph = snapshot.graph.bind(project, next_generation)?;
330 validate_snapshot_classification_coverage(&self.connection, &graph.file_classifications)?;
331 before_publication()?;
332 let mut guard = if projection {
333 self.begin_index_projection_refresh_from(
334 &snapshot.metadata.capability_fingerprint,
335 base_generation,
336 )?
337 } else {
338 self.begin_index_publication_from(
339 &snapshot.metadata.capability_fingerprint,
340 base_generation,
341 )?
342 };
343 if source_state_digest(&guard.connection)? != snapshot.metadata.source_state_digest {
344 return invalid("destination source state does not match the snapshot");
345 }
346 validate_snapshot_classification_coverage(&guard.connection, &graph.file_classifications)?;
347 for rows in graph
348 .file_classifications
349 .chunks(MAX_FILE_CONTENT_CLASSIFICATION_PATHS)
350 {
351 guard.upsert_file_content_classification_batch(rows)?;
352 }
353 guard.replace_repository_graph_with_resolution_keys(
354 project,
355 &graph.entities,
356 &graph.relations,
357 &graph.occurrences,
358 &graph.coverage,
359 &graph.entity_exports,
360 &graph.relation_dependencies,
361 )?;
362 guard.set_document_unresolved_reasons(&graph.document_unresolved_reasons)?;
363 guard.complete()?;
364 Ok(DerivedGraphSnapshotImport {
365 previous_generation: base_generation,
366 published_generation: next_generation,
367 digest: snapshot.digest.clone(),
368 content: snapshot.content.clone(),
369 })
370 }
371
372 pub fn import_derived_graph_snapshot(
385 &mut self,
386 snapshot: &DerivedGraphSnapshot,
387 ) -> DbResult<DerivedGraphSnapshotImport> {
388 self.import_derived_graph_snapshot_with_prepublication(snapshot, || Ok(()))
389 }
390
391 fn import_derived_graph_snapshot_with_prepublication(
393 &mut self,
394 snapshot: &DerivedGraphSnapshot,
395 before_publication: impl FnOnce() -> DbResult<()>,
396 ) -> DbResult<DerivedGraphSnapshotImport> {
397 snapshot.validate()?;
398 let publication = self
399 .index_publication()?
400 .filter(|publication| {
401 publication.state == IndexPublicationState::Complete
402 && publication.generation != IndexGeneration::ZERO
403 })
404 .ok_or(DbError::GraphPublicationUnavailable)?;
405 if publication.contract_fingerprint.as_deref()
406 != Some(snapshot.metadata.capability_fingerprint.as_str())
407 {
408 return invalid("destination capability fingerprint does not match the snapshot");
409 }
410 if source_state_digest(&self.connection)? != snapshot.metadata.source_state_digest {
411 return invalid("destination source state does not match the snapshot");
412 }
413 let project = self
414 .project_instance_id()?
415 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
416 self.publish_snapshot(
417 snapshot,
418 project,
419 publication.generation,
420 true,
421 before_publication,
422 )
423 }
424}
425
426fn snapshot_from_stable_capture(capture: &Connection) -> DbResult<DerivedGraphSnapshot> {
428 require_private_capture_size(capture)?;
429 let quick_check = capture.query_row("PRAGMA quick_check", [], |row| row.get::<_, String>(0))?;
430 if quick_check != "ok" {
431 return invalid("private SQLite capture failed integrity check");
432 }
433
434 let publication = load_index_publication(capture)?
435 .filter(|publication| {
436 publication.state == IndexPublicationState::Complete
437 && publication.generation != IndexGeneration::ZERO
438 })
439 .ok_or(DbError::GraphPublicationUnavailable)?;
440 let capability_fingerprint = publication
441 .contract_fingerprint
442 .filter(|value| !value.is_empty())
443 .ok_or(DbError::DerivedSnapshotInvalid {
444 reason: "complete publication has no capability fingerprint",
445 })?;
446 let project = load_project_identity(capture)?.ok_or(DbError::ProjectInstanceIdentityMissing)?;
447 if load_graph_generation(capture)? != Some(publication.generation) {
448 return invalid("private capture graph generation is not complete");
449 }
450 let source_state_digest = source_state_digest(capture)?;
451 let mut budget = SnapshotBudget::new();
452 let mut captured = repository_graph::capture_derived_graph(
453 capture,
454 project,
455 publication.generation,
456 &mut budget,
457 )?;
458 captured.file_classifications = capture_file_classifications(capture, &mut budget)?;
459 DerivedGraphSnapshot::from_capture(
460 captured,
461 publication.generation,
462 source_state_digest,
463 capability_fingerprint,
464 )
465}
466
467impl DerivedGraphSnapshot {
468 fn from_capture(
470 captured: CapturedGraph,
471 source_generation: IndexGeneration,
472 source_state_digest: String,
473 capability_fingerprint: String,
474 ) -> DbResult<Self> {
475 let graph = PortableGraph::from_capture(captured)?;
476 let metadata = DerivedGraphSnapshotMetadata {
477 runtime_version: env!("CARGO_PKG_VERSION").to_string(),
478 schema_version: SCHEMA_VERSION,
479 root: DERIVED_SNAPSHOT_ROOT.to_string(),
480 source_generation,
481 source_state_digest,
482 capability_fingerprint,
483 };
484 let content = expected_content(&graph)?;
485 let digest = snapshot_digest(&metadata, &content, &graph)?;
486 let snapshot = Self {
487 format_version: DERIVED_SNAPSHOT_FORMAT_VERSION,
488 digest,
489 metadata,
490 content,
491 graph,
492 };
493 snapshot.validate()?;
494 Ok(snapshot)
495 }
496}
497
498#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
500struct PortableGraph {
501 file_classifications: Vec<PortableFileClassification>,
503 entities: Vec<EntitySelector>,
505 relations: Vec<PortableRelation>,
507 occurrences: Vec<PortableOccurrence>,
509 coverage: Vec<PortableCoverage>,
511 entity_exports: Vec<PortableEntityResolutionKey>,
513 relation_dependencies: Vec<PortableRelationResolutionKey>,
515}
516
517impl PortableGraph {
518 fn from_capture(captured: CapturedGraph) -> DbResult<Self> {
520 let file_classifications = captured
521 .file_classifications
522 .into_iter()
523 .map(PortableFileClassification::from)
524 .collect();
525 let mut entity_indexes = BTreeMap::new();
526 for (index, entity) in captured.entities.iter().enumerate() {
527 let index = usize_to_u32(index)?;
528 if entity_indexes
529 .insert(entity.key().digest_bytes()?, index)
530 .is_some()
531 {
532 return invalid("snapshot contains duplicate entity identities");
533 }
534 }
535 let mut relation_indexes = BTreeMap::new();
536 for (index, relation) in captured.relations.iter().enumerate() {
537 let index = usize_to_u32(index)?;
538 if relation_indexes
539 .insert(relation.key().digest_bytes()?, index)
540 .is_some()
541 {
542 return invalid("snapshot contains duplicate relation identities");
543 }
544 }
545 let document_unresolved_reasons = captured
546 .document_unresolved_reasons
547 .into_iter()
548 .collect::<BTreeMap<_, _>>();
549 let entities = captured
550 .entities
551 .iter()
552 .map(|entity| entity.selector().clone())
553 .collect();
554 let relations = captured
555 .relations
556 .iter()
557 .map(|relation| {
558 PortableRelation::from_relation(
559 relation,
560 document_unresolved_reasons
561 .get(&relation.key().digest_bytes()?)
562 .copied(),
563 &entity_indexes,
564 )
565 })
566 .collect::<DbResult<Vec<_>>>()?;
567 let occurrences = captured
568 .occurrences
569 .iter()
570 .map(|occurrence| {
571 Ok(PortableOccurrence {
572 relation: required_index(
573 &relation_indexes,
574 occurrence.relation().digest_bytes()?,
575 "snapshot occurrence owner relation is absent",
576 )?,
577 file: occurrence.file().clone(),
578 span: occurrence.span(),
579 })
580 })
581 .collect::<DbResult<Vec<_>>>()?;
582 let coverage = captured
583 .coverage
584 .iter()
585 .map(PortableCoverage::from)
586 .collect();
587 let entity_exports = captured
588 .entity_exports
589 .into_iter()
590 .map(|(entity, key)| {
591 Ok(PortableEntityResolutionKey {
592 entity: required_index(
593 &entity_indexes,
594 entity,
595 "snapshot resolution export entity is absent",
596 )?,
597 key: key.portable()?,
598 })
599 })
600 .collect::<DbResult<Vec<_>>>()?;
601 let relation_dependencies = captured
602 .relation_dependencies
603 .into_iter()
604 .map(|(relation, key)| {
605 Ok(PortableRelationResolutionKey {
606 relation: required_index(
607 &relation_indexes,
608 relation,
609 "snapshot resolution dependency relation is absent",
610 )?,
611 key: key.portable()?,
612 })
613 })
614 .collect::<DbResult<Vec<_>>>()?;
615 Ok(Self {
616 file_classifications,
617 entities,
618 relations,
619 occurrences,
620 coverage,
621 entity_exports,
622 relation_dependencies,
623 })
624 }
625
626 fn validate(&self) -> DbResult<()> {
628 let total = [
629 self.file_classifications.len(),
630 self.entities.len(),
631 self.relations.len(),
632 self.occurrences.len(),
633 self.coverage.len(),
634 self.entity_exports.len(),
635 self.relation_dependencies.len(),
636 ]
637 .into_iter()
638 .try_fold(0_u64, |total, rows| {
639 total
640 .checked_add(usize_to_u64(rows)?)
641 .ok_or(DbError::DerivedSnapshotInvalid {
642 reason: "snapshot row count overflowed",
643 })
644 })?;
645 require_limit("decoded rows", total, MAX_DERIVED_SNAPSHOT_ROWS)?;
646 let mut classified_paths = BTreeSet::new();
647 for row in &self.file_classifications {
648 RepositoryFilePath::new(std::path::Path::new(&row.path))?;
649 if !classified_paths.insert(row.path.as_str()) {
650 return invalid("snapshot contains duplicate file classifications");
651 }
652 }
653 for relation in &self.relations {
654 require_vector_index(
655 relation.source,
656 self.entities.len(),
657 "snapshot relation source index is invalid",
658 )?;
659 match relation.resolution {
660 PortableRelationResolution::Resolved { target }
661 | PortableRelationResolution::External { target } => require_vector_index(
662 target,
663 self.entities.len(),
664 "snapshot relation target index is invalid",
665 )?,
666 PortableRelationResolution::Ambiguous { .. }
667 | PortableRelationResolution::Unresolved { .. } => {}
668 }
669 let requires_reason = relation.kind
670 == GraphRelationKind::Extended(ExtendedRelationKind::Documents)
671 && matches!(
672 relation.resolution,
673 PortableRelationResolution::Unresolved { .. }
674 );
675 if requires_reason != relation.document_unresolved_reason.is_some() {
676 return invalid(
677 "snapshot document reason contradicts relation family or resolution",
678 );
679 }
680 }
681 for occurrence in &self.occurrences {
682 require_vector_index(
683 occurrence.relation,
684 self.relations.len(),
685 "snapshot occurrence relation index is invalid",
686 )?;
687 }
688 for export in &self.entity_exports {
689 require_vector_index(
690 export.entity,
691 self.entities.len(),
692 "snapshot export entity index is invalid",
693 )?;
694 }
695 for dependency in &self.relation_dependencies {
696 require_vector_index(
697 dependency.relation,
698 self.relations.len(),
699 "snapshot dependency relation index is invalid",
700 )?;
701 }
702 Ok(())
703 }
704
705 fn bind(
707 &self,
708 project: ProjectInstanceId,
709 generation: IndexGeneration,
710 ) -> DbResult<BoundGraph> {
711 self.validate()?;
712 let entities = self
713 .entities
714 .iter()
715 .cloned()
716 .map(|selector| GraphEntity::new(project, selector, generation).map_err(Into::into))
717 .collect::<DbResult<Vec<_>>>()?;
718 let file_classifications = self
719 .file_classifications
720 .iter()
721 .cloned()
722 .map(FileContentClassification::from)
723 .collect();
724 let relations = self
725 .relations
726 .iter()
727 .map(|relation| relation.bind(&entities, generation))
728 .collect::<DbResult<Vec<_>>>()?;
729 let occurrences = self
730 .occurrences
731 .iter()
732 .map(|occurrence| {
733 let relation = indexed(
734 &relations,
735 occurrence.relation,
736 "snapshot occurrence relation index is invalid",
737 )?;
738 RelationOccurrence::new(
739 relation,
740 occurrence.file.clone(),
741 occurrence.span,
742 generation,
743 )
744 .map_err(Into::into)
745 })
746 .collect::<DbResult<Vec<_>>>()?;
747 let coverage = self
748 .coverage
749 .iter()
750 .map(|coverage| coverage.bind(generation))
751 .collect::<DbResult<Vec<_>>>()?;
752 let entity_exports = self
753 .entity_exports
754 .iter()
755 .map(|export| {
756 EntityResolutionKey::new(
757 indexed(
758 &entities,
759 export.entity,
760 "snapshot export entity index is invalid",
761 )?
762 .key()
763 .clone(),
764 export.key.bind(project),
765 )
766 .map_err(Into::into)
767 })
768 .collect::<DbResult<Vec<_>>>()?;
769 let relation_dependencies = self
770 .relation_dependencies
771 .iter()
772 .map(|dependency| {
773 RelationDependencyKey::new(
774 indexed(
775 &relations,
776 dependency.relation,
777 "snapshot dependency relation index is invalid",
778 )?
779 .key()
780 .clone(),
781 dependency.key.bind(project),
782 )
783 .map_err(Into::into)
784 })
785 .collect::<DbResult<Vec<_>>>()?;
786 let document_unresolved_reasons = self
787 .relations
788 .iter()
789 .zip(&relations)
790 .filter_map(|(portable, relation)| {
791 portable
792 .document_unresolved_reason
793 .map(|reason| (relation.key().clone(), reason))
794 })
795 .collect();
796 Ok(BoundGraph {
797 file_classifications,
798 entities,
799 relations,
800 occurrences,
801 coverage,
802 entity_exports,
803 relation_dependencies,
804 document_unresolved_reasons,
805 })
806 }
807}
808
809#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
811struct PortableFileClassification {
812 path: String,
814 classification: ContentClassification,
816}
817
818impl From<FileContentClassification> for PortableFileClassification {
819 fn from(row: FileContentClassification) -> Self {
820 Self {
821 path: row.path,
822 classification: row.classification,
823 }
824 }
825}
826
827impl From<PortableFileClassification> for FileContentClassification {
828 fn from(row: PortableFileClassification) -> Self {
829 Self {
830 path: row.path,
831 classification: row.classification,
832 }
833 }
834}
835
836#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
838struct PortableRelation {
839 source: u32,
841 kind: GraphRelationKind,
843 resolution: PortableRelationResolution,
845 confidence: ConfidenceClass,
847 completeness: Completeness,
849 #[serde(skip_serializing_if = "Option::is_none")]
851 document_unresolved_reason: Option<DocumentTargetUnresolvedReason>,
852}
853
854impl PortableRelation {
855 fn from_relation(
857 relation: &LogicalRelation,
858 document_unresolved_reason: Option<DocumentTargetUnresolvedReason>,
859 entity_indexes: &BTreeMap<[u8; 32], u32>,
860 ) -> DbResult<Self> {
861 let resolution = match relation.resolution() {
862 RelationResolution::Resolved { target, .. } => PortableRelationResolution::Resolved {
863 target: required_index(
864 entity_indexes,
865 target.digest_bytes()?,
866 "snapshot resolved target is absent",
867 )?,
868 },
869 RelationResolution::Ambiguous {
870 reference,
871 candidates,
872 } => PortableRelationResolution::Ambiguous {
873 reference: reference.clone(),
874 candidates: *candidates,
875 },
876 RelationResolution::Unresolved { reference } => {
877 PortableRelationResolution::Unresolved {
878 reference: reference.clone(),
879 }
880 }
881 RelationResolution::External { target, .. } => PortableRelationResolution::External {
882 target: required_index(
883 entity_indexes,
884 target.digest_bytes()?,
885 "snapshot external target is absent",
886 )?,
887 },
888 };
889 Ok(Self {
890 source: required_index(
891 entity_indexes,
892 relation.source().digest_bytes()?,
893 "snapshot relation source is absent",
894 )?,
895 kind: relation.kind(),
896 resolution,
897 confidence: relation.confidence(),
898 completeness: relation.completeness(),
899 document_unresolved_reason,
900 })
901 }
902
903 fn bind(
905 &self,
906 entities: &[GraphEntity],
907 generation: IndexGeneration,
908 ) -> DbResult<LogicalRelation> {
909 let source = indexed(
910 entities,
911 self.source,
912 "snapshot relation source index is invalid",
913 )?;
914 let resolution = match &self.resolution {
915 PortableRelationResolution::Resolved { target } => {
916 RelationResolution::resolved(indexed(
917 entities,
918 *target,
919 "snapshot resolved target index is invalid",
920 )?)?
921 }
922 PortableRelationResolution::Ambiguous {
923 reference,
924 candidates,
925 } => RelationResolution::Ambiguous {
926 reference: reference.clone(),
927 candidates: *candidates,
928 },
929 PortableRelationResolution::Unresolved { reference } => {
930 RelationResolution::Unresolved {
931 reference: reference.clone(),
932 }
933 }
934 PortableRelationResolution::External { target } => {
935 RelationResolution::external(indexed(
936 entities,
937 *target,
938 "snapshot external target index is invalid",
939 )?)?
940 }
941 };
942 LogicalRelation::new(
943 source,
944 self.kind,
945 resolution,
946 self.confidence,
947 self.completeness,
948 generation,
949 )
950 .map_err(Into::into)
951 }
952}
953
954#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
956#[serde(tag = "status", rename_all = "snake_case")]
957enum PortableRelationResolution {
958 Resolved {
960 target: u32,
962 },
963 Ambiguous {
965 reference: GraphIdentityText,
967 candidates: NonZeroU32,
969 },
970 Unresolved {
972 reference: GraphIdentityText,
974 },
975 External {
977 target: u32,
979 },
980}
981
982#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
984struct PortableOccurrence {
985 relation: u32,
987 file: RepositoryFilePath,
989 span: SourceSpan,
991}
992
993#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
995struct PortableCoverage {
996 scope: CoverageScope,
998 relation: Option<GraphRelationKind>,
1000 state: CoverageState,
1002 covered: u64,
1004 omitted: u64,
1006 reason: Option<GraphIdentityText>,
1008 reached_limit: Option<GraphLimitKind>,
1010}
1011
1012impl From<&CoverageRecord> for PortableCoverage {
1013 fn from(coverage: &CoverageRecord) -> Self {
1014 Self {
1015 scope: coverage.scope().clone(),
1016 relation: coverage.relation(),
1017 state: coverage.state(),
1018 covered: coverage.covered(),
1019 omitted: coverage.omitted(),
1020 reason: coverage.reason().cloned(),
1021 reached_limit: coverage.reached_limit(),
1022 }
1023 }
1024}
1025
1026impl PortableCoverage {
1027 fn bind(&self, generation: IndexGeneration) -> DbResult<CoverageRecord> {
1029 CoverageRecord::new(
1030 self.scope.clone(),
1031 self.relation,
1032 self.state,
1033 self.covered,
1034 self.omitted,
1035 generation,
1036 self.reason.clone(),
1037 self.reached_limit,
1038 )
1039 .map_err(Into::into)
1040 }
1041}
1042
1043#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1045struct PortableEntityResolutionKey {
1046 entity: u32,
1048 key: PortableResolutionKey,
1050}
1051
1052#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1054struct PortableRelationResolutionKey {
1055 relation: u32,
1057 key: PortableResolutionKey,
1059}
1060
1061struct BoundGraph {
1063 file_classifications: Vec<FileContentClassification>,
1065 entities: Vec<GraphEntity>,
1067 relations: Vec<LogicalRelation>,
1069 occurrences: Vec<RelationOccurrence>,
1071 coverage: Vec<CoverageRecord>,
1073 entity_exports: Vec<EntityResolutionKey>,
1075 relation_dependencies: Vec<RelationDependencyKey>,
1077 document_unresolved_reasons: Vec<(LogicalRelationKey, DocumentTargetUnresolvedReason)>,
1079}
1080
1081#[derive(Serialize)]
1083struct SnapshotDigestBody<'a> {
1084 metadata: &'a DerivedGraphSnapshotMetadata,
1086 content: &'a [DerivedSnapshotContent],
1088 graph: &'a PortableGraph,
1090}
1091
1092fn snapshot_digest(
1094 metadata: &DerivedGraphSnapshotMetadata,
1095 content: &[DerivedSnapshotContent],
1096 graph: &PortableGraph,
1097) -> DbResult<String> {
1098 let encoded = serde_json::to_vec(&SnapshotDigestBody {
1099 metadata,
1100 content,
1101 graph,
1102 })?;
1103 require_limit(
1104 "digest body bytes",
1105 usize_to_u64(encoded.len())?,
1106 MAX_DERIVED_SNAPSHOT_JSON_BYTES,
1107 )?;
1108 Ok(blake3::hash(&encoded).to_hex().to_string())
1109}
1110
1111fn expected_content(graph: &PortableGraph) -> DbResult<Vec<DerivedSnapshotContent>> {
1113 Ok(vec![
1114 content(
1115 "file_content_classifications",
1116 &["path", "classification"],
1117 graph.file_classifications.len(),
1118 )?,
1119 content("graph_entities", &["entity_selector"], graph.entities.len())?,
1120 content(
1121 "graph_relations",
1122 &[
1123 "source_entity",
1124 "relation_kind",
1125 "resolution",
1126 "document_unresolved_reason",
1127 "confidence",
1128 "completeness",
1129 ],
1130 graph.relations.len(),
1131 )?,
1132 content(
1133 "graph_relation_occurrences",
1134 &["relation", "file_path", "source_span"],
1135 graph.occurrences.len(),
1136 )?,
1137 content(
1138 "graph_coverage",
1139 &[
1140 "scope",
1141 "relation_kind",
1142 "state",
1143 "covered",
1144 "omitted",
1145 "reason",
1146 "reached_limit",
1147 ],
1148 graph.coverage.len(),
1149 )?,
1150 content(
1151 "graph_entity_exports+graph_resolution_keys",
1152 &["entity", "resolution_domain", "portable_canonical_identity"],
1153 graph.entity_exports.len(),
1154 )?,
1155 content(
1156 "graph_relation_dependencies+graph_resolution_keys",
1157 &[
1158 "relation",
1159 "resolution_domain",
1160 "portable_canonical_identity",
1161 ],
1162 graph.relation_dependencies.len(),
1163 )?,
1164 ])
1165}
1166
1167fn content(table: &str, columns: &[&str], rows: usize) -> DbResult<DerivedSnapshotContent> {
1169 Ok(DerivedSnapshotContent {
1170 table: table.to_string(),
1171 columns: columns.iter().map(|column| (*column).to_string()).collect(),
1172 rows: usize_to_u64(rows)?,
1173 })
1174}
1175
1176fn capture_file_classifications(
1178 connection: &Connection,
1179 budget: &mut SnapshotBudget,
1180) -> DbResult<Vec<FileContentClassification>> {
1181 let mut statement = connection.prepare(
1182 "SELECT path, classification
1183 FROM file_content_classifications
1184 ORDER BY path",
1185 )?;
1186 let mut rows = statement.query([])?;
1187 let mut captured = Vec::new();
1188 while let Some(row) = rows.next()? {
1189 let path = row.get::<_, String>(0)?;
1190 let raw = row.get::<_, String>(1)?;
1191 let classification =
1192 ContentClassification::from_db(&raw).ok_or_else(|| DbError::InvalidEnum {
1193 field: "file_content_classifications.classification",
1194 value: raw.clone(),
1195 })?;
1196 budget.admit(
1197 usize_to_u64(path.len())?
1198 .saturating_add(usize_to_u64(raw.len())?)
1199 .saturating_add(DERIVED_SNAPSHOT_DECODE_OBJECT_BYTES),
1200 )?;
1201 captured.push(FileContentClassification {
1202 path,
1203 classification,
1204 });
1205 }
1206 Ok(captured)
1207}
1208
1209fn validate_snapshot_classification_coverage(
1211 connection: &Connection,
1212 classifications: &[FileContentClassification],
1213) -> DbResult<()> {
1214 let mut statement = connection.prepare(
1215 "SELECT path
1216 FROM nodes
1217 WHERE exists_now = 1 AND kind = 'file'
1218 ORDER BY path",
1219 )?;
1220 let mut rows = statement.query([])?;
1221 let mut index = 0_usize;
1222 while let Some(row) = rows.next()? {
1223 let path = row.get::<_, String>(0)?;
1224 if classifications.get(index).map(|row| row.path.as_str()) != Some(path.as_str()) {
1225 return invalid("snapshot classifications do not exactly cover current files");
1226 }
1227 index = index.saturating_add(1);
1228 }
1229 if index != classifications.len() {
1230 return invalid("snapshot classifications do not exactly cover current files");
1231 }
1232 Ok(())
1233}
1234
1235fn source_state_digest(connection: &Connection) -> DbResult<String> {
1237 let mut statement = connection.prepare(
1238 "SELECT path, kind, extension, language, size_bytes, content_hash
1239 FROM nodes
1240 WHERE exists_now = 1
1241 ORDER BY path",
1242 )?;
1243 let mut rows = statement.query([])?;
1244 let mut hasher = Hasher::new();
1245 hasher.update(b"projectatlas.derived-snapshot.source-state.v1");
1246 let mut row_count = 0_u64;
1247 let mut byte_count = 0_u64;
1248 while let Some(row) = rows.next()? {
1249 row_count = row_count
1250 .checked_add(1)
1251 .ok_or(DbError::DerivedSnapshotInvalid {
1252 reason: "source-state row count overflowed",
1253 })?;
1254 require_limit("source-state rows", row_count, MAX_SOURCE_STATE_ROWS)?;
1255 let fields = [
1256 row.get::<_, String>(0)?,
1257 row.get::<_, String>(1)?,
1258 row.get::<_, Option<String>>(2)?.unwrap_or_default(),
1259 row.get::<_, Option<String>>(3)?.unwrap_or_default(),
1260 row.get::<_, Option<i64>>(4)?
1261 .map_or_else(String::new, |value| value.to_string()),
1262 row.get::<_, Option<String>>(5)?.unwrap_or_default(),
1263 ];
1264 for field in fields {
1265 byte_count = byte_count.checked_add(usize_to_u64(field.len())?).ok_or(
1266 DbError::DerivedSnapshotInvalid {
1267 reason: "source-state byte count overflowed",
1268 },
1269 )?;
1270 require_limit(
1271 "source-state metadata bytes",
1272 byte_count,
1273 MAX_SOURCE_STATE_BYTES,
1274 )?;
1275 hash_field(&mut hasher, field.as_bytes())?;
1276 }
1277 }
1278 hasher.update(&row_count.to_le_bytes());
1279 Ok(hasher.finalize().to_hex().to_string())
1280}
1281
1282fn hash_field(hasher: &mut Hasher, value: &[u8]) -> DbResult<()> {
1284 let length = usize_to_u64(value.len())?;
1285 hasher.update(&length.to_le_bytes());
1286 hasher.update(value);
1287 Ok(())
1288}
1289
1290fn require_private_capture_size(connection: &Connection) -> DbResult<()> {
1292 let page_count =
1293 u64::try_from(connection.query_row("PRAGMA page_count", [], |row| row.get::<_, i64>(0))?)
1294 .map_err(|_source| DbError::DerivedSnapshotInvalid {
1295 reason: "private SQLite capture page count was negative",
1296 })?;
1297 let page_size =
1298 u64::try_from(connection.query_row("PRAGMA page_size", [], |row| row.get::<_, i64>(0))?)
1299 .map_err(|_source| DbError::DerivedSnapshotInvalid {
1300 reason: "private SQLite capture page size was negative",
1301 })?;
1302 let bytes = page_count
1303 .checked_mul(page_size)
1304 .ok_or(DbError::DerivedSnapshotInvalid {
1305 reason: "private SQLite capture size overflowed",
1306 })?;
1307 require_limit(
1308 "private SQLite capture bytes",
1309 bytes,
1310 MAX_PRIVATE_CAPTURE_BYTES,
1311 )
1312}
1313
1314fn required_index(
1316 indexes: &BTreeMap<[u8; 32], u32>,
1317 key: [u8; 32],
1318 reason: &'static str,
1319) -> DbResult<u32> {
1320 indexes
1321 .get(&key)
1322 .copied()
1323 .ok_or(DbError::DerivedSnapshotInvalid { reason })
1324}
1325
1326fn require_vector_index(index: u32, length: usize, reason: &'static str) -> DbResult<()> {
1328 if usize::try_from(index).is_ok_and(|index| index < length) {
1329 Ok(())
1330 } else {
1331 invalid(reason)
1332 }
1333}
1334
1335fn require_decode_budget(encoded: &[u8]) -> DbResult<()> {
1337 let mut retained_bytes = 0_u64;
1338 let mut string_bytes = 0_usize;
1339 let mut in_string = false;
1340 let mut escaped = false;
1341 let mut in_primitive = false;
1342
1343 for byte in encoded {
1344 if in_string {
1345 if escaped {
1346 escaped = false;
1347 } else if *byte == b'\\' {
1348 escaped = true;
1349 } else if *byte == b'"' {
1350 in_string = false;
1351 in_primitive = true;
1352 admit_decode_bytes(&mut retained_bytes, usize_to_u64(string_bytes)?)?;
1353 string_bytes = 0;
1354 continue;
1355 }
1356 string_bytes = string_bytes
1357 .checked_add(1)
1358 .ok_or(DbError::DerivedSnapshotInvalid {
1359 reason: "snapshot JSON string size overflowed",
1360 })?;
1361 if string_bytes > MAX_DERIVED_SNAPSHOT_JSON_STRING_BYTES {
1362 return Err(DbError::DerivedSnapshotLimit {
1363 resource: "encoded JSON string bytes",
1364 found: usize_to_u64(string_bytes)?,
1365 maximum: usize_to_u64(MAX_DERIVED_SNAPSHOT_JSON_STRING_BYTES)?,
1366 });
1367 }
1368 continue;
1369 }
1370
1371 match *byte {
1372 b'"' => {
1373 in_string = true;
1374 escaped = false;
1375 in_primitive = false;
1376 admit_decode_bytes(&mut retained_bytes, DERIVED_SNAPSHOT_DECODE_HEADER_BYTES)?;
1377 }
1378 b'{' => {
1379 in_primitive = false;
1380 admit_decode_bytes(&mut retained_bytes, DERIVED_SNAPSHOT_DECODE_OBJECT_BYTES)?;
1381 }
1382 b'[' => {
1383 in_primitive = false;
1384 admit_decode_bytes(&mut retained_bytes, DERIVED_SNAPSHOT_DECODE_HEADER_BYTES)?;
1385 }
1386 b',' | b':' | b'}' | b']' | b' ' | b'\t' | b'\r' | b'\n' => {
1387 in_primitive = false;
1388 }
1389 _ if !in_primitive => {
1390 in_primitive = true;
1391 admit_decode_bytes(&mut retained_bytes, DERIVED_SNAPSHOT_DECODE_PRIMITIVE_BYTES)?;
1392 }
1393 _ => {}
1394 }
1395 }
1396 Ok(())
1397}
1398
1399fn admit_decode_bytes(retained_bytes: &mut u64, bytes: u64) -> DbResult<()> {
1401 *retained_bytes = retained_bytes
1402 .checked_add(bytes)
1403 .ok_or(DbError::DerivedSnapshotInvalid {
1404 reason: "snapshot retained byte count overflowed",
1405 })?;
1406 require_limit(
1407 "decoded retained bytes",
1408 *retained_bytes,
1409 MAX_DERIVED_SNAPSHOT_RETAINED_BYTES,
1410 )
1411}
1412
1413fn indexed<'a, T>(values: &'a [T], index: u32, reason: &'static str) -> DbResult<&'a T> {
1415 values
1416 .get(usize::try_from(index).map_err(|_source| DbError::DerivedSnapshotInvalid { reason })?)
1417 .ok_or(DbError::DerivedSnapshotInvalid { reason })
1418}
1419
1420fn require_limit(resource: &'static str, found: u64, maximum: u64) -> DbResult<()> {
1422 if found <= maximum {
1423 Ok(())
1424 } else {
1425 Err(DbError::DerivedSnapshotLimit {
1426 resource,
1427 found,
1428 maximum,
1429 })
1430 }
1431}
1432
1433fn valid_digest(value: &str) -> bool {
1435 value.len() == BLAKE3_HEX_BYTES
1436 && value
1437 .bytes()
1438 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1439}
1440
1441fn usize_to_u64(value: usize) -> DbResult<u64> {
1443 u64::try_from(value).map_err(|_source| DbError::DerivedSnapshotInvalid {
1444 reason: "snapshot size cannot be represented",
1445 })
1446}
1447
1448fn usize_to_u32(value: usize) -> DbResult<u32> {
1450 u32::try_from(value).map_err(|_source| DbError::DerivedSnapshotLimit {
1451 resource: "portable row indexes",
1452 found: u64::try_from(value).unwrap_or(u64::MAX),
1453 maximum: u64::from(u32::MAX),
1454 })
1455}
1456
1457fn invalid<T>(reason: &'static str) -> DbResult<T> {
1459 Err(DbError::DerivedSnapshotInvalid { reason })
1460}
1461
1462#[cfg(test)]
1463mod tests {
1464 use super::{
1465 DerivedGraphSnapshot, MAX_DERIVED_SNAPSHOT_JSON_STRING_BYTES, expected_content, invalid,
1466 snapshot_digest,
1467 };
1468 use crate::{AtlasStore, DbError, FileContentClassification};
1469 use projectatlas_core::graph::{
1470 CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
1471 CoverageState, DocumentTargetUnresolvedReason, EntityResolutionKey, EntitySelector,
1472 ExtendedRelationKind, GraphEntity, GraphIdentityText, GraphRelationKind, LogicalRelation,
1473 RelationDependencyKey, RelationOccurrence, RelationResolution, RepositoryFilePath,
1474 ResolutionKeyDomain, SourceSpan, SymbolSelector,
1475 };
1476 use projectatlas_core::language::ContentClassification;
1477 use projectatlas_core::symbols::{RelationKind, SymbolKind};
1478 use projectatlas_core::telemetry::UsageEvent;
1479 use projectatlas_core::{IndexGeneration, Node, NodeKind, PurposeSource};
1480 use serde_json::json;
1481 use std::error::Error;
1482 use std::fs;
1483 use std::path::Path;
1484
1485 const CONTRACT: &str = "snapshot-test-contract";
1486 const PRIVATE_SENTINEL: &str = "TOP_SECRET_SNAPSHOT_SENTINEL";
1487 const DELETED_PRIVATE_SENTINEL: &str = "DELETED_SNAPSHOT_PAGE_SENTINEL";
1488
1489 fn node(
1490 path: &str,
1491 kind: NodeKind,
1492 parent_path: Option<&str>,
1493 content_hash: Option<&str>,
1494 ) -> Node {
1495 Node {
1496 path: path.to_string(),
1497 kind,
1498 parent_path: parent_path.map(str::to_string),
1499 extension: (kind == NodeKind::File).then(|| ".rs".to_string()),
1500 language: (kind == NodeKind::File).then(|| "rust".to_string()),
1501 size_bytes: (kind == NodeKind::File).then_some(32),
1502 mtime_ns: (kind == NodeKind::File).then_some(1),
1503 content_hash: content_hash.map(str::to_string),
1504 }
1505 }
1506
1507 fn open_store(root: &Path) -> Result<AtlasStore, Box<dyn Error>> {
1508 let atlas = root.join(".projectatlas");
1509 fs::create_dir_all(&atlas)?;
1510 Ok(AtlasStore::open_for_project(
1511 &atlas.join("projectatlas.db"),
1512 root,
1513 )?)
1514 }
1515
1516 fn publish_fixture(
1517 store: &mut AtlasStore,
1518 content_hash: &str,
1519 with_graph: bool,
1520 ) -> Result<(), Box<dyn Error>> {
1521 let project = store
1522 .project_instance_id()?
1523 .ok_or("fixture project identity is missing")?;
1524 let generation = IndexGeneration::new(1);
1525 let mut publication = store.begin_index_publication(CONTRACT)?;
1526 publication.begin_scan_replacement()?;
1527 publication.upsert_scan_node_batch(&[
1528 node(".", NodeKind::Folder, None, None),
1529 node("src", NodeKind::Folder, Some("."), None),
1530 node(
1531 "src/lib.rs",
1532 NodeKind::File,
1533 Some("src"),
1534 Some(content_hash),
1535 ),
1536 ])?;
1537 publication.finish_scan_replacement()?;
1538 publication.upsert_file_content_classification_batch(&[FileContentClassification {
1539 path: "src/lib.rs".to_string(),
1540 classification: if with_graph {
1541 ContentClassification::Source
1542 } else {
1543 ContentClassification::Documentation
1544 },
1545 }])?;
1546 if with_graph {
1547 let project_entity = GraphEntity::new(project, EntitySelector::Project, generation)?;
1548 let file = GraphEntity::new(
1549 project,
1550 EntitySelector::File {
1551 path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
1552 },
1553 generation,
1554 )?;
1555 let symbol = GraphEntity::new(
1556 project,
1557 EntitySelector::Symbol {
1558 symbol: SymbolSelector {
1559 file: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
1560 name: GraphIdentityText::new("answer")?,
1561 kind: SymbolKind::Function,
1562 parent: None,
1563 signature: GraphIdentityText::new("fn answer() -> u32")?,
1564 },
1565 },
1566 generation,
1567 )?;
1568 let relation = LogicalRelation::new(
1569 &file,
1570 GraphRelationKind::Legacy(RelationKind::Calls),
1571 RelationResolution::resolved(&symbol)?,
1572 ConfidenceClass::Exact,
1573 Completeness::Complete,
1574 generation,
1575 )?;
1576 let unresolved_document = LogicalRelation::new(
1577 &file,
1578 GraphRelationKind::Extended(ExtendedRelationKind::Documents),
1579 RelationResolution::Unresolved {
1580 reference: GraphIdentityText::new("docs/missing.md")?,
1581 },
1582 ConfidenceClass::High,
1583 Completeness::Complete,
1584 generation,
1585 )?;
1586 let occurrence = RelationOccurrence::new(
1587 &relation,
1588 RepositoryFilePath::new(Path::new("src/lib.rs"))?,
1589 SourceSpan::new(1, 0, 1, 6)?,
1590 generation,
1591 )?;
1592 let document_occurrence = RelationOccurrence::new(
1593 &unresolved_document,
1594 RepositoryFilePath::new(Path::new("src/lib.rs"))?,
1595 SourceSpan::new(2, 0, 2, 15)?,
1596 generation,
1597 )?;
1598 let coverage = CoverageRecord::new(
1599 CoverageScope::Project,
1600 None,
1601 CoverageState::Complete,
1602 1,
1603 0,
1604 generation,
1605 None,
1606 None,
1607 )?;
1608 let key = CanonicalResolutionKey::new(
1609 project,
1610 ResolutionKeyDomain::Declaration,
1611 &GraphIdentityText::new("tree-sitter")?,
1612 &GraphIdentityText::new("rust")?,
1613 None,
1614 Some(&GraphIdentityText::new("crate")?),
1615 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
1616 &GraphIdentityText::new("answer")?,
1617 );
1618 publication.replace_repository_graph_with_resolution_keys(
1619 project,
1620 &[project_entity, file, symbol.clone()],
1621 &[relation.clone(), unresolved_document.clone()],
1622 &[occurrence, document_occurrence],
1623 &[coverage],
1624 &[EntityResolutionKey::new(symbol.key().clone(), key.clone())?],
1625 &[RelationDependencyKey::new(relation.key().clone(), key)?],
1626 )?;
1627 publication.set_document_unresolved_reasons(&[(
1628 unresolved_document.key().clone(),
1629 DocumentTargetUnresolvedReason::Missing,
1630 )])?;
1631 } else {
1632 publication.replace_repository_graph_with_resolution_keys(
1633 project,
1634 &[],
1635 &[],
1636 &[],
1637 &[],
1638 &[],
1639 &[],
1640 )?;
1641 }
1642 publication.complete()?;
1643 Ok(())
1644 }
1645
1646 fn seed_private_state(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
1647 store.set_purpose("src/lib.rs", PRIVATE_SENTINEL, PurposeSource::Agent)?;
1648 store.connection.execute(
1649 "INSERT INTO health_resolutions(
1650 finding_id, category, path, related_path, rationale
1651 ) VALUES('snapshot-private-health', 'snapshot-private', 'src/lib.rs', NULL, ?1)",
1652 [PRIVATE_SENTINEL],
1653 )?;
1654 let usage = serde_json::from_value::<UsageEvent>(json!({
1655 "session_id": "snapshot-private",
1656 "command": "summary",
1657 "query": PRIVATE_SENTINEL
1658 }))?;
1659 store.record_usage(&usage)?;
1660 store.connection.execute(
1661 "INSERT INTO metadata(key, value) VALUES('snapshot.private.setting', ?1)",
1662 [PRIVATE_SENTINEL],
1663 )?;
1664 store.connection.execute_batch(
1665 "CREATE TABLE future_memory_atlas(secret TEXT);
1666 INSERT INTO future_memory_atlas(secret)
1667 VALUES('TOP_SECRET_SNAPSHOT_SENTINEL');",
1668 )?;
1669 store.connection.execute(
1670 "INSERT INTO metadata(key, value) VALUES('snapshot.deleted.secret', ?1)",
1671 [DELETED_PRIVATE_SENTINEL],
1672 )?;
1673 store.connection.execute(
1674 "DELETE FROM metadata WHERE key = 'snapshot.deleted.secret'",
1675 [],
1676 )?;
1677 Ok(())
1678 }
1679
1680 #[test]
1681 fn malformed_snapshot_json_is_rejected_before_use() {
1682 let result = DerivedGraphSnapshot::from_json(br#"{"format_version":1}"#);
1683 assert!(result.is_err());
1684 assert!(invalid::<()>("fixture").is_err());
1685 }
1686
1687 #[test]
1688 fn snapshot_json_decode_budget_rejects_large_shapes_before_deserialization() {
1689 let mut wide = String::from("[");
1690 for index in 0..2_100_000 {
1691 if index != 0 {
1692 wide.push(',');
1693 }
1694 wide.push_str("{}");
1695 }
1696 wide.push(']');
1697 assert!(matches!(
1698 DerivedGraphSnapshot::from_json(wide.as_bytes()),
1699 Err(DbError::DerivedSnapshotLimit {
1700 resource: "decoded retained bytes",
1701 ..
1702 })
1703 ));
1704
1705 let oversized_string = format!(
1706 r#"{{"value":"{}"}}"#,
1707 "x".repeat(MAX_DERIVED_SNAPSHOT_JSON_STRING_BYTES + 1)
1708 );
1709 assert!(matches!(
1710 DerivedGraphSnapshot::from_json(oversized_string.as_bytes()),
1711 Err(DbError::DerivedSnapshotLimit {
1712 resource: "encoded JSON string bytes",
1713 ..
1714 })
1715 ));
1716 }
1717
1718 #[test]
1719 fn large_valid_snapshot_round_trips_without_charging_json_delimiters()
1720 -> Result<(), Box<dyn Error>> {
1721 let source_root = tempfile::tempdir()?;
1722 let mut source = open_store(source_root.path())?;
1723 publish_fixture(&mut source, "large-round-trip", true)?;
1724 let mut snapshot = source.export_derived_graph_snapshot()?;
1725 let coverage = snapshot
1726 .graph
1727 .coverage
1728 .first()
1729 .ok_or("fixture coverage is missing")?
1730 .clone();
1731 snapshot.graph.coverage = vec![coverage; 40_000];
1732 snapshot.content = expected_content(&snapshot.graph)?;
1733 snapshot.digest = snapshot_digest(&snapshot.metadata, &snapshot.content, &snapshot.graph)?;
1734
1735 let encoded = snapshot.to_json()?;
1736 let decoded = DerivedGraphSnapshot::from_json(&encoded)?;
1737 if decoded != snapshot {
1738 return Err("large valid snapshot changed during round trip".into());
1739 }
1740 Ok(())
1741 }
1742
1743 #[test]
1744 #[allow(clippy::panic_in_result_fn)]
1745 fn derived_snapshot_excludes_private_state_and_rebinds_atomically() -> Result<(), Box<dyn Error>>
1746 {
1747 let source_root = tempfile::tempdir()?;
1748 let destination_root = tempfile::tempdir()?;
1749 let mut source = open_store(source_root.path())?;
1750 let mut destination = open_store(destination_root.path())?;
1751 publish_fixture(&mut source, "same-content", true)?;
1752 publish_fixture(&mut destination, "same-content", false)?;
1753 seed_private_state(&source)?;
1754 seed_private_state(&destination)?;
1755
1756 let source_identity = source
1757 .project_instance_id()?
1758 .ok_or("source identity is missing")?;
1759 let destination_identity = destination
1760 .project_instance_id()?
1761 .ok_or("destination identity is missing")?;
1762 assert_ne!(source_identity, destination_identity);
1763
1764 let exported = source.export_derived_graph_snapshot()?;
1765 let encoded = exported.to_json()?;
1766 let encoded_text = String::from_utf8(encoded.clone())?;
1767 assert!(!encoded_text.contains(PRIVATE_SENTINEL));
1768 assert!(!encoded_text.contains(DELETED_PRIVATE_SENTINEL));
1769 assert!(!encoded_text.contains(&source_identity.to_string()));
1770 let escaped_source_root =
1771 serde_json::to_string(source_root.path().to_string_lossy().as_ref())?;
1772 assert!(!encoded_text.contains(escaped_source_root.trim_matches('"')));
1773 assert_eq!(
1774 exported.content().iter().map(|row| row.rows).sum::<u64>(),
1775 11
1776 );
1777
1778 let decoded = DerivedGraphSnapshot::from_json(&encoded)?;
1779 let report = destination.import_derived_graph_snapshot(&decoded)?;
1780 assert_eq!(report.previous_generation, IndexGeneration::new(1));
1781 assert_eq!(report.published_generation, IndexGeneration::new(2));
1782 assert_eq!(
1783 destination.project_instance_id()?,
1784 Some(destination_identity)
1785 );
1786 let entities = destination.repository_graph_entities_by_path(
1787 destination_identity,
1788 &projectatlas_core::graph::RepositoryNodePath::new(Path::new("src/lib.rs"))?,
1789 10,
1790 )?;
1791 assert_eq!(entities.rows.len(), 2);
1792 assert_eq!(
1793 destination.file_content_classifications_for_paths(&["src/lib.rs".to_string()])?[0]
1794 .classification,
1795 ContentClassification::Source
1796 );
1797 assert_eq!(
1798 destination.connection.query_row(
1799 "SELECT COUNT(*) FROM graph_relations
1800 WHERE relation_scope = 'extended'
1801 AND relation_kind = 'documents'
1802 AND resolution_status = 'unresolved'
1803 AND document_unresolved_reason = 'missing'",
1804 [],
1805 |row| row.get::<_, i64>(0),
1806 )?,
1807 1
1808 );
1809 let indexed = destination.load_nodes_by_paths(&["src/lib.rs".to_string()])?;
1810 assert_eq!(
1811 indexed[0].purpose.purpose.as_deref(),
1812 Some(PRIVATE_SENTINEL)
1813 );
1814 assert_eq!(
1815 destination.connection.query_row(
1816 "SELECT rationale FROM health_resolutions
1817 WHERE finding_id = 'snapshot-private-health'",
1818 [],
1819 |row| row.get::<_, String>(0),
1820 )?,
1821 PRIVATE_SENTINEL
1822 );
1823 assert_eq!(
1824 destination.connection.query_row(
1825 "SELECT value FROM metadata WHERE key = 'snapshot.private.setting'",
1826 [],
1827 |row| row.get::<_, String>(0),
1828 )?,
1829 PRIVATE_SENTINEL
1830 );
1831 assert_eq!(
1832 destination.connection.query_row(
1833 "SELECT query FROM usage_events ORDER BY id DESC LIMIT 1",
1834 [],
1835 |row| row.get::<_, String>(0),
1836 )?,
1837 PRIVATE_SENTINEL
1838 );
1839 assert_eq!(
1840 destination.connection.query_row(
1841 "SELECT secret FROM future_memory_atlas",
1842 [],
1843 |row| row.get::<_, String>(0),
1844 )?,
1845 PRIVATE_SENTINEL
1846 );
1847 Ok(())
1848 }
1849
1850 #[test]
1851 #[allow(clippy::panic_in_result_fn)]
1852 fn derived_snapshot_rejects_document_reason_drift_and_rolls_back_update_failure()
1853 -> Result<(), Box<dyn Error>> {
1854 let source_root = tempfile::tempdir()?;
1855 let destination_root = tempfile::tempdir()?;
1856 let mut source = open_store(source_root.path())?;
1857 let mut destination = open_store(destination_root.path())?;
1858 publish_fixture(&mut source, "same-content", true)?;
1859 publish_fixture(&mut destination, "same-content", false)?;
1860
1861 let snapshot = source.export_derived_graph_snapshot()?;
1862 let mut malformed = snapshot.clone();
1863 let document = malformed
1864 .graph
1865 .relations
1866 .iter_mut()
1867 .find(|relation| {
1868 relation.kind == GraphRelationKind::Extended(ExtendedRelationKind::Documents)
1869 })
1870 .ok_or("document relation is missing")?;
1871 document.document_unresolved_reason = None;
1872 malformed.content = expected_content(&malformed.graph)?;
1873 malformed.digest =
1874 snapshot_digest(&malformed.metadata, &malformed.content, &malformed.graph)?;
1875 assert!(matches!(
1876 malformed.to_json(),
1877 Err(DbError::DerivedSnapshotInvalid {
1878 reason: "snapshot document reason contradicts relation family or resolution"
1879 })
1880 ));
1881
1882 let mut forbidden = snapshot.clone();
1883 let calls = forbidden
1884 .graph
1885 .relations
1886 .iter_mut()
1887 .find(|relation| relation.kind == GraphRelationKind::Legacy(RelationKind::Calls))
1888 .ok_or("calls relation is missing")?;
1889 calls.document_unresolved_reason = Some(DocumentTargetUnresolvedReason::Missing);
1890 forbidden.content = expected_content(&forbidden.graph)?;
1891 forbidden.digest =
1892 snapshot_digest(&forbidden.metadata, &forbidden.content, &forbidden.graph)?;
1893 assert!(matches!(
1894 forbidden.to_json(),
1895 Err(DbError::DerivedSnapshotInvalid {
1896 reason: "snapshot document reason contradicts relation family or resolution"
1897 })
1898 ));
1899
1900 destination.connection.execute_batch(
1901 "CREATE TEMP TRIGGER fail_snapshot_document_reason
1902 BEFORE UPDATE OF document_unresolved_reason ON graph_relations
1903 BEGIN
1904 SELECT RAISE(ABORT, 'injected snapshot reason failure');
1905 END;",
1906 )?;
1907 let before = destination
1908 .index_publication()?
1909 .ok_or("destination publication is missing")?;
1910 assert!(
1911 destination
1912 .import_derived_graph_snapshot(&snapshot)
1913 .is_err()
1914 );
1915 assert_eq!(
1916 destination
1917 .index_publication()?
1918 .ok_or("destination publication is missing")?,
1919 before
1920 );
1921 assert_eq!(
1922 destination.connection.query_row(
1923 "SELECT COUNT(*) FROM graph_relations",
1924 [],
1925 |row| { row.get::<_, i64>(0) }
1926 )?,
1927 0
1928 );
1929 Ok(())
1930 }
1931
1932 #[test]
1933 #[allow(clippy::panic_in_result_fn)]
1934 fn derived_snapshot_rejects_tampering_and_source_mismatch_without_publication()
1935 -> Result<(), Box<dyn Error>> {
1936 let source_root = tempfile::tempdir()?;
1937 let destination_root = tempfile::tempdir()?;
1938 let mut source = open_store(source_root.path())?;
1939 let mut destination = open_store(destination_root.path())?;
1940 publish_fixture(&mut source, "source-content", true)?;
1941 publish_fixture(&mut destination, "different-content", false)?;
1942
1943 let snapshot = source.export_derived_graph_snapshot()?;
1944 let mut tampered = snapshot;
1945 tampered.metadata.source_state_digest =
1946 "0".repeat(tampered.metadata.source_state_digest.len());
1947 assert!(tampered.to_json().is_err());
1948
1949 let mut internally_consistent = tampered;
1950 internally_consistent.digest = snapshot_digest(
1951 &internally_consistent.metadata,
1952 &internally_consistent.content,
1953 &internally_consistent.graph,
1954 )?;
1955 let before = destination
1956 .index_publication()?
1957 .ok_or("publication missing")?;
1958 assert!(
1959 destination
1960 .import_derived_graph_snapshot(&internally_consistent)
1961 .is_err()
1962 );
1963 assert_eq!(
1964 destination
1965 .index_publication()?
1966 .ok_or("publication missing")?,
1967 before
1968 );
1969 assert_eq!(
1970 destination.repository_graph_generation()?,
1971 Some(before.generation)
1972 );
1973 Ok(())
1974 }
1975
1976 #[test]
1977 #[allow(clippy::panic_in_result_fn)]
1978 fn derived_snapshot_rechecks_source_state_inside_publication_transaction()
1979 -> Result<(), Box<dyn Error>> {
1980 let source_root = tempfile::tempdir()?;
1981 let destination_root = tempfile::tempdir()?;
1982 let mut source = open_store(source_root.path())?;
1983 let mut destination = open_store(destination_root.path())?;
1984 publish_fixture(&mut source, "same-content", true)?;
1985 publish_fixture(&mut destination, "same-content", false)?;
1986 let mut concurrent = open_store(destination_root.path())?;
1987 let snapshot = source.export_derived_graph_snapshot()?;
1988 let before = destination
1989 .index_publication()?
1990 .ok_or("destination publication is missing")?;
1991
1992 let result =
1993 destination.import_derived_graph_snapshot_with_prepublication(&snapshot, || {
1994 concurrent.upsert_scan_nodes(&[node(
1995 "src/lib.rs",
1996 NodeKind::File,
1997 Some("src"),
1998 Some("concurrent-content"),
1999 )])
2000 });
2001 assert!(matches!(
2002 result,
2003 Err(DbError::DerivedSnapshotInvalid {
2004 reason: "destination source state does not match the snapshot"
2005 })
2006 ));
2007 assert_eq!(
2008 destination
2009 .index_publication()?
2010 .ok_or("destination publication is missing")?,
2011 before
2012 );
2013 assert_eq!(
2014 destination.repository_graph_generation()?,
2015 Some(before.generation)
2016 );
2017 assert_eq!(
2018 destination.load_nodes_by_paths(&["src/lib.rs".to_string()])?[0]
2019 .node
2020 .content_hash
2021 .as_deref(),
2022 Some("concurrent-content")
2023 );
2024 Ok(())
2025 }
2026}