1use crate::project_identity::{load_graph_generation, load_project_identity};
4use crate::repository_graph;
5use crate::schema::SCHEMA_VERSION;
6use crate::{AtlasStore, DbError, DbResult, IndexPublicationState, load_index_publication};
7use blake3::Hasher;
8use projectatlas_core::IndexGeneration;
9use projectatlas_core::graph::{
10 CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
11 CoverageState, EntityResolutionKey, EntitySelector, GraphEntity, GraphIdentityText,
12 GraphLimitKind, GraphRelationKind, LogicalRelation, PortableResolutionKey, ProjectInstanceId,
13 RelationDependencyKey, RelationOccurrence, RelationResolution, RepositoryFilePath, SourceSpan,
14};
15use rusqlite::Connection;
16use rusqlite::backup::Backup;
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19use std::num::NonZeroU32;
20use std::time::Duration;
21
22const DERIVED_SNAPSHOT_FORMAT_VERSION: u32 = 1;
24const DERIVED_SNAPSHOT_ROOT: &str = ".";
26pub const MAX_DERIVED_SNAPSHOT_JSON_BYTES: u64 = 512 * 1024 * 1024;
28const MAX_DERIVED_SNAPSHOT_ROWS: u64 = 1_000_000;
30const MAX_DERIVED_SNAPSHOT_RETAINED_BYTES: u64 = 256 * 1024 * 1024;
32const DERIVED_SNAPSHOT_DECODE_OBJECT_BYTES: u64 = 128;
34const DERIVED_SNAPSHOT_DECODE_HEADER_BYTES: u64 = 24;
36const DERIVED_SNAPSHOT_DECODE_PRIMITIVE_BYTES: u64 = 16;
38const MAX_DERIVED_SNAPSHOT_JSON_STRING_BYTES: usize = 256 * 1024;
40const MAX_PRIVATE_CAPTURE_BYTES: u64 = 16 * 1024 * 1024 * 1024;
42const MAX_SOURCE_STATE_ROWS: u64 = 5_000_000;
44const MAX_SOURCE_STATE_BYTES: u64 = 512 * 1024 * 1024;
46const BLAKE3_HEX_BYTES: usize = 64;
48
49#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
51pub struct DerivedGraphSnapshotMetadata {
52 pub runtime_version: String,
54 pub schema_version: i64,
56 pub root: String,
58 pub source_generation: IndexGeneration,
60 pub source_state_digest: String,
62 pub capability_fingerprint: String,
64}
65
66#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
68pub struct DerivedSnapshotContent {
69 pub table: String,
71 pub columns: Vec<String>,
73 pub rows: u64,
75}
76
77#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
79pub struct DerivedGraphSnapshot {
80 format_version: u32,
82 digest: String,
84 metadata: DerivedGraphSnapshotMetadata,
86 content: Vec<DerivedSnapshotContent>,
88 graph: PortableGraph,
90}
91
92impl DerivedGraphSnapshot {
93 #[must_use]
95 pub const fn metadata(&self) -> &DerivedGraphSnapshotMetadata {
96 &self.metadata
97 }
98
99 #[must_use]
101 pub fn content(&self) -> &[DerivedSnapshotContent] {
102 &self.content
103 }
104
105 #[must_use]
107 pub fn digest(&self) -> &str {
108 &self.digest
109 }
110
111 pub fn to_json(&self) -> DbResult<Vec<u8>> {
118 self.validate()?;
119 let encoded = serde_json::to_vec(self)?;
120 require_limit(
121 "encoded JSON bytes",
122 usize_to_u64(encoded.len())?,
123 MAX_DERIVED_SNAPSHOT_JSON_BYTES,
124 )?;
125 require_decode_budget(&encoded)?;
126 Ok(encoded)
127 }
128
129 pub fn from_json(encoded: &[u8]) -> DbResult<Self> {
136 require_limit(
137 "encoded JSON bytes",
138 usize_to_u64(encoded.len())?,
139 MAX_DERIVED_SNAPSHOT_JSON_BYTES,
140 )?;
141 require_decode_budget(encoded)?;
142 let snapshot = serde_json::from_slice::<Self>(encoded)?;
143 snapshot.validate()?;
144 Ok(snapshot)
145 }
146
147 fn validate(&self) -> DbResult<()> {
149 if self.format_version != DERIVED_SNAPSHOT_FORMAT_VERSION {
150 return invalid("unsupported portable format version");
151 }
152 if self.metadata.runtime_version != env!("CARGO_PKG_VERSION") {
153 return invalid("snapshot runtime version does not match this runtime");
154 }
155 if self.metadata.schema_version != SCHEMA_VERSION {
156 return invalid("snapshot schema version does not match this runtime");
157 }
158 if self.metadata.root != DERIVED_SNAPSHOT_ROOT {
159 return invalid("snapshot root is not the portable repository root");
160 }
161 if self.metadata.source_generation == IndexGeneration::ZERO {
162 return invalid("snapshot source generation is zero");
163 }
164 if !valid_digest(&self.metadata.source_state_digest) {
165 return invalid("snapshot source-state digest is malformed");
166 }
167 if self.metadata.capability_fingerprint.is_empty()
168 || self.metadata.capability_fingerprint.len() > 4_096
169 || self
170 .metadata
171 .capability_fingerprint
172 .chars()
173 .any(char::is_control)
174 {
175 return invalid("snapshot capability fingerprint is invalid");
176 }
177 self.graph.validate()?;
178 if self.content != expected_content(&self.graph)? {
179 return invalid("snapshot content inventory does not match the portable graph");
180 }
181 let digest = snapshot_digest(&self.metadata, &self.content, &self.graph)?;
182 if self.digest != digest {
183 return invalid("snapshot content digest does not match");
184 }
185 Ok(())
186 }
187}
188
189#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
191pub struct DerivedGraphSnapshotImport {
192 pub previous_generation: IndexGeneration,
194 pub published_generation: IndexGeneration,
196 pub digest: String,
198 pub content: Vec<DerivedSnapshotContent>,
200}
201
202pub(crate) struct CapturedGraph {
204 pub(crate) entities: Vec<GraphEntity>,
206 pub(crate) relations: Vec<LogicalRelation>,
208 pub(crate) occurrences: Vec<RelationOccurrence>,
210 pub(crate) coverage: Vec<CoverageRecord>,
212 pub(crate) entity_exports: Vec<([u8; 32], CanonicalResolutionKey)>,
214 pub(crate) relation_dependencies: Vec<([u8; 32], CanonicalResolutionKey)>,
216}
217
218pub(crate) struct SnapshotBudget {
220 rows: u64,
222 retained_bytes: u64,
224}
225
226impl SnapshotBudget {
227 pub(crate) const fn new() -> Self {
229 Self {
230 rows: 0,
231 retained_bytes: 0,
232 }
233 }
234
235 pub(crate) fn admit(&mut self, bytes: u64) -> DbResult<()> {
237 self.rows = self
238 .rows
239 .checked_add(1)
240 .ok_or(DbError::DerivedSnapshotInvalid {
241 reason: "snapshot row count overflowed",
242 })?;
243 require_limit("decoded rows", self.rows, MAX_DERIVED_SNAPSHOT_ROWS)?;
244 self.retained_bytes = self
245 .retained_bytes
246 .checked_add(bytes)
247 .and_then(|value| value.checked_add(128))
248 .ok_or(DbError::DerivedSnapshotInvalid {
249 reason: "snapshot retained byte count overflowed",
250 })?;
251 require_limit(
252 "decoded retained bytes",
253 self.retained_bytes,
254 MAX_DERIVED_SNAPSHOT_RETAINED_BYTES,
255 )
256 }
257}
258
259impl AtlasStore {
260 pub fn export_derived_graph_snapshot(&self) -> DbResult<DerivedGraphSnapshot> {
267 let capture_dir = tempfile::tempdir().map_err(|source| DbError::DerivedSnapshotIo {
268 path: std::env::temp_dir(),
269 source,
270 })?;
271 let capture_path = capture_dir.path().join("derived-graph-capture.sqlite");
272 let mut capture = Connection::open(&capture_path).map_err(DbError::from)?;
273 require_private_capture_size(&self.connection)?;
274 {
275 let backup = Backup::new(&self.connection, &mut capture)?;
276 backup.run_to_completion(256, Duration::from_millis(1), None)?;
277 }
278 let quick_check =
279 capture.query_row("PRAGMA quick_check", [], |row| row.get::<_, String>(0))?;
280 if quick_check != "ok" {
281 return invalid("private SQLite capture failed integrity check");
282 }
283
284 let publication = load_index_publication(&capture)?
285 .filter(|publication| {
286 publication.state == IndexPublicationState::Complete
287 && publication.generation != IndexGeneration::ZERO
288 })
289 .ok_or(DbError::GraphPublicationUnavailable)?;
290 let capability_fingerprint = publication
291 .contract_fingerprint
292 .filter(|value| !value.is_empty())
293 .ok_or(DbError::DerivedSnapshotInvalid {
294 reason: "complete publication has no capability fingerprint",
295 })?;
296 let project =
297 load_project_identity(&capture)?.ok_or(DbError::ProjectInstanceIdentityMissing)?;
298 if load_graph_generation(&capture)? != Some(publication.generation) {
299 return invalid("private capture graph generation is not complete");
300 }
301 let source_state_digest = source_state_digest(&capture)?;
302 let mut budget = SnapshotBudget::new();
303 let captured = repository_graph::capture_derived_graph(
304 &capture,
305 project,
306 publication.generation,
307 &mut budget,
308 )?;
309 DerivedGraphSnapshot::from_capture(
310 captured,
311 publication.generation,
312 source_state_digest,
313 capability_fingerprint,
314 )
315 }
316
317 pub fn import_derived_graph_snapshot(
330 &mut self,
331 snapshot: &DerivedGraphSnapshot,
332 ) -> DbResult<DerivedGraphSnapshotImport> {
333 self.import_derived_graph_snapshot_with_prepublication(snapshot, || Ok(()))
334 }
335
336 fn import_derived_graph_snapshot_with_prepublication(
338 &mut self,
339 snapshot: &DerivedGraphSnapshot,
340 before_publication: impl FnOnce() -> DbResult<()>,
341 ) -> DbResult<DerivedGraphSnapshotImport> {
342 snapshot.validate()?;
343 let publication = self
344 .index_publication()?
345 .filter(|publication| {
346 publication.state == IndexPublicationState::Complete
347 && publication.generation != IndexGeneration::ZERO
348 })
349 .ok_or(DbError::GraphPublicationUnavailable)?;
350 if publication.contract_fingerprint.as_deref()
351 != Some(snapshot.metadata.capability_fingerprint.as_str())
352 {
353 return invalid("destination capability fingerprint does not match the snapshot");
354 }
355 if source_state_digest(&self.connection)? != snapshot.metadata.source_state_digest {
356 return invalid("destination source state does not match the snapshot");
357 }
358 let project = self
359 .project_instance_id()?
360 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
361 let next_generation = publication
362 .generation
363 .checked_next()
364 .ok_or(DbError::PublicationGenerationOverflow)?;
365 let graph = snapshot.graph.bind(project, next_generation)?;
366 before_publication()?;
367 let mut guard = self.begin_index_projection_refresh_from(
368 &snapshot.metadata.capability_fingerprint,
369 publication.generation,
370 )?;
371 if source_state_digest(&guard.connection)? != snapshot.metadata.source_state_digest {
372 return invalid("destination source state does not match the snapshot");
373 }
374 guard.replace_repository_graph_with_resolution_keys(
375 project,
376 &graph.entities,
377 &graph.relations,
378 &graph.occurrences,
379 &graph.coverage,
380 &graph.entity_exports,
381 &graph.relation_dependencies,
382 )?;
383 guard.complete()?;
384 Ok(DerivedGraphSnapshotImport {
385 previous_generation: publication.generation,
386 published_generation: next_generation,
387 digest: snapshot.digest.clone(),
388 content: snapshot.content.clone(),
389 })
390 }
391}
392
393impl DerivedGraphSnapshot {
394 fn from_capture(
396 captured: CapturedGraph,
397 source_generation: IndexGeneration,
398 source_state_digest: String,
399 capability_fingerprint: String,
400 ) -> DbResult<Self> {
401 let graph = PortableGraph::from_capture(captured)?;
402 let metadata = DerivedGraphSnapshotMetadata {
403 runtime_version: env!("CARGO_PKG_VERSION").to_string(),
404 schema_version: SCHEMA_VERSION,
405 root: DERIVED_SNAPSHOT_ROOT.to_string(),
406 source_generation,
407 source_state_digest,
408 capability_fingerprint,
409 };
410 let content = expected_content(&graph)?;
411 let digest = snapshot_digest(&metadata, &content, &graph)?;
412 let snapshot = Self {
413 format_version: DERIVED_SNAPSHOT_FORMAT_VERSION,
414 digest,
415 metadata,
416 content,
417 graph,
418 };
419 snapshot.validate()?;
420 Ok(snapshot)
421 }
422}
423
424#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
426struct PortableGraph {
427 entities: Vec<EntitySelector>,
429 relations: Vec<PortableRelation>,
431 occurrences: Vec<PortableOccurrence>,
433 coverage: Vec<PortableCoverage>,
435 entity_exports: Vec<PortableEntityResolutionKey>,
437 relation_dependencies: Vec<PortableRelationResolutionKey>,
439}
440
441impl PortableGraph {
442 fn from_capture(captured: CapturedGraph) -> DbResult<Self> {
444 let mut entity_indexes = BTreeMap::new();
445 for (index, entity) in captured.entities.iter().enumerate() {
446 let index = usize_to_u32(index)?;
447 if entity_indexes
448 .insert(entity.key().digest_bytes()?, index)
449 .is_some()
450 {
451 return invalid("snapshot contains duplicate entity identities");
452 }
453 }
454 let mut relation_indexes = BTreeMap::new();
455 for (index, relation) in captured.relations.iter().enumerate() {
456 let index = usize_to_u32(index)?;
457 if relation_indexes
458 .insert(relation.key().digest_bytes()?, index)
459 .is_some()
460 {
461 return invalid("snapshot contains duplicate relation identities");
462 }
463 }
464 let entities = captured
465 .entities
466 .iter()
467 .map(|entity| entity.selector().clone())
468 .collect();
469 let relations = captured
470 .relations
471 .iter()
472 .map(|relation| PortableRelation::from_relation(relation, &entity_indexes))
473 .collect::<DbResult<Vec<_>>>()?;
474 let occurrences = captured
475 .occurrences
476 .iter()
477 .map(|occurrence| {
478 Ok(PortableOccurrence {
479 relation: required_index(
480 &relation_indexes,
481 occurrence.relation().digest_bytes()?,
482 "snapshot occurrence owner relation is absent",
483 )?,
484 file: occurrence.file().clone(),
485 span: occurrence.span(),
486 })
487 })
488 .collect::<DbResult<Vec<_>>>()?;
489 let coverage = captured
490 .coverage
491 .iter()
492 .map(PortableCoverage::from)
493 .collect();
494 let entity_exports = captured
495 .entity_exports
496 .into_iter()
497 .map(|(entity, key)| {
498 Ok(PortableEntityResolutionKey {
499 entity: required_index(
500 &entity_indexes,
501 entity,
502 "snapshot resolution export entity is absent",
503 )?,
504 key: key.portable()?,
505 })
506 })
507 .collect::<DbResult<Vec<_>>>()?;
508 let relation_dependencies = captured
509 .relation_dependencies
510 .into_iter()
511 .map(|(relation, key)| {
512 Ok(PortableRelationResolutionKey {
513 relation: required_index(
514 &relation_indexes,
515 relation,
516 "snapshot resolution dependency relation is absent",
517 )?,
518 key: key.portable()?,
519 })
520 })
521 .collect::<DbResult<Vec<_>>>()?;
522 Ok(Self {
523 entities,
524 relations,
525 occurrences,
526 coverage,
527 entity_exports,
528 relation_dependencies,
529 })
530 }
531
532 fn validate(&self) -> DbResult<()> {
534 let total = [
535 self.entities.len(),
536 self.relations.len(),
537 self.occurrences.len(),
538 self.coverage.len(),
539 self.entity_exports.len(),
540 self.relation_dependencies.len(),
541 ]
542 .into_iter()
543 .try_fold(0_u64, |total, rows| {
544 total
545 .checked_add(usize_to_u64(rows)?)
546 .ok_or(DbError::DerivedSnapshotInvalid {
547 reason: "snapshot row count overflowed",
548 })
549 })?;
550 require_limit("decoded rows", total, MAX_DERIVED_SNAPSHOT_ROWS)?;
551 for relation in &self.relations {
552 require_vector_index(
553 relation.source,
554 self.entities.len(),
555 "snapshot relation source index is invalid",
556 )?;
557 match relation.resolution {
558 PortableRelationResolution::Resolved { target }
559 | PortableRelationResolution::External { target } => require_vector_index(
560 target,
561 self.entities.len(),
562 "snapshot relation target index is invalid",
563 )?,
564 PortableRelationResolution::Ambiguous { .. }
565 | PortableRelationResolution::Unresolved { .. } => {}
566 }
567 }
568 for occurrence in &self.occurrences {
569 require_vector_index(
570 occurrence.relation,
571 self.relations.len(),
572 "snapshot occurrence relation index is invalid",
573 )?;
574 }
575 for export in &self.entity_exports {
576 require_vector_index(
577 export.entity,
578 self.entities.len(),
579 "snapshot export entity index is invalid",
580 )?;
581 }
582 for dependency in &self.relation_dependencies {
583 require_vector_index(
584 dependency.relation,
585 self.relations.len(),
586 "snapshot dependency relation index is invalid",
587 )?;
588 }
589 Ok(())
590 }
591
592 fn bind(
594 &self,
595 project: ProjectInstanceId,
596 generation: IndexGeneration,
597 ) -> DbResult<BoundGraph> {
598 self.validate()?;
599 let entities = self
600 .entities
601 .iter()
602 .cloned()
603 .map(|selector| GraphEntity::new(project, selector, generation).map_err(Into::into))
604 .collect::<DbResult<Vec<_>>>()?;
605 let relations = self
606 .relations
607 .iter()
608 .map(|relation| relation.bind(&entities, generation))
609 .collect::<DbResult<Vec<_>>>()?;
610 let occurrences = self
611 .occurrences
612 .iter()
613 .map(|occurrence| {
614 let relation = indexed(
615 &relations,
616 occurrence.relation,
617 "snapshot occurrence relation index is invalid",
618 )?;
619 RelationOccurrence::new(
620 relation,
621 occurrence.file.clone(),
622 occurrence.span,
623 generation,
624 )
625 .map_err(Into::into)
626 })
627 .collect::<DbResult<Vec<_>>>()?;
628 let coverage = self
629 .coverage
630 .iter()
631 .map(|coverage| coverage.bind(generation))
632 .collect::<DbResult<Vec<_>>>()?;
633 let entity_exports = self
634 .entity_exports
635 .iter()
636 .map(|export| {
637 EntityResolutionKey::new(
638 indexed(
639 &entities,
640 export.entity,
641 "snapshot export entity index is invalid",
642 )?
643 .key()
644 .clone(),
645 export.key.bind(project),
646 )
647 .map_err(Into::into)
648 })
649 .collect::<DbResult<Vec<_>>>()?;
650 let relation_dependencies = self
651 .relation_dependencies
652 .iter()
653 .map(|dependency| {
654 RelationDependencyKey::new(
655 indexed(
656 &relations,
657 dependency.relation,
658 "snapshot dependency relation index is invalid",
659 )?
660 .key()
661 .clone(),
662 dependency.key.bind(project),
663 )
664 .map_err(Into::into)
665 })
666 .collect::<DbResult<Vec<_>>>()?;
667 Ok(BoundGraph {
668 entities,
669 relations,
670 occurrences,
671 coverage,
672 entity_exports,
673 relation_dependencies,
674 })
675 }
676}
677
678#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
680struct PortableRelation {
681 source: u32,
683 kind: GraphRelationKind,
685 resolution: PortableRelationResolution,
687 confidence: ConfidenceClass,
689 completeness: Completeness,
691}
692
693impl PortableRelation {
694 fn from_relation(
696 relation: &LogicalRelation,
697 entity_indexes: &BTreeMap<[u8; 32], u32>,
698 ) -> DbResult<Self> {
699 let resolution = match relation.resolution() {
700 RelationResolution::Resolved { target, .. } => PortableRelationResolution::Resolved {
701 target: required_index(
702 entity_indexes,
703 target.digest_bytes()?,
704 "snapshot resolved target is absent",
705 )?,
706 },
707 RelationResolution::Ambiguous {
708 reference,
709 candidates,
710 } => PortableRelationResolution::Ambiguous {
711 reference: reference.clone(),
712 candidates: *candidates,
713 },
714 RelationResolution::Unresolved { reference } => {
715 PortableRelationResolution::Unresolved {
716 reference: reference.clone(),
717 }
718 }
719 RelationResolution::External { target, .. } => PortableRelationResolution::External {
720 target: required_index(
721 entity_indexes,
722 target.digest_bytes()?,
723 "snapshot external target is absent",
724 )?,
725 },
726 };
727 Ok(Self {
728 source: required_index(
729 entity_indexes,
730 relation.source().digest_bytes()?,
731 "snapshot relation source is absent",
732 )?,
733 kind: relation.kind(),
734 resolution,
735 confidence: relation.confidence(),
736 completeness: relation.completeness(),
737 })
738 }
739
740 fn bind(
742 &self,
743 entities: &[GraphEntity],
744 generation: IndexGeneration,
745 ) -> DbResult<LogicalRelation> {
746 let source = indexed(
747 entities,
748 self.source,
749 "snapshot relation source index is invalid",
750 )?;
751 let resolution = match &self.resolution {
752 PortableRelationResolution::Resolved { target } => {
753 RelationResolution::resolved(indexed(
754 entities,
755 *target,
756 "snapshot resolved target index is invalid",
757 )?)?
758 }
759 PortableRelationResolution::Ambiguous {
760 reference,
761 candidates,
762 } => RelationResolution::Ambiguous {
763 reference: reference.clone(),
764 candidates: *candidates,
765 },
766 PortableRelationResolution::Unresolved { reference } => {
767 RelationResolution::Unresolved {
768 reference: reference.clone(),
769 }
770 }
771 PortableRelationResolution::External { target } => {
772 RelationResolution::external(indexed(
773 entities,
774 *target,
775 "snapshot external target index is invalid",
776 )?)?
777 }
778 };
779 LogicalRelation::new(
780 source,
781 self.kind,
782 resolution,
783 self.confidence,
784 self.completeness,
785 generation,
786 )
787 .map_err(Into::into)
788 }
789}
790
791#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
793#[serde(tag = "status", rename_all = "snake_case")]
794enum PortableRelationResolution {
795 Resolved {
797 target: u32,
799 },
800 Ambiguous {
802 reference: GraphIdentityText,
804 candidates: NonZeroU32,
806 },
807 Unresolved {
809 reference: GraphIdentityText,
811 },
812 External {
814 target: u32,
816 },
817}
818
819#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
821struct PortableOccurrence {
822 relation: u32,
824 file: RepositoryFilePath,
826 span: SourceSpan,
828}
829
830#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
832struct PortableCoverage {
833 scope: CoverageScope,
835 relation: Option<GraphRelationKind>,
837 state: CoverageState,
839 covered: u64,
841 omitted: u64,
843 reason: Option<GraphIdentityText>,
845 reached_limit: Option<GraphLimitKind>,
847}
848
849impl From<&CoverageRecord> for PortableCoverage {
850 fn from(coverage: &CoverageRecord) -> Self {
851 Self {
852 scope: coverage.scope().clone(),
853 relation: coverage.relation(),
854 state: coverage.state(),
855 covered: coverage.covered(),
856 omitted: coverage.omitted(),
857 reason: coverage.reason().cloned(),
858 reached_limit: coverage.reached_limit(),
859 }
860 }
861}
862
863impl PortableCoverage {
864 fn bind(&self, generation: IndexGeneration) -> DbResult<CoverageRecord> {
866 CoverageRecord::new(
867 self.scope.clone(),
868 self.relation,
869 self.state,
870 self.covered,
871 self.omitted,
872 generation,
873 self.reason.clone(),
874 self.reached_limit,
875 )
876 .map_err(Into::into)
877 }
878}
879
880#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
882struct PortableEntityResolutionKey {
883 entity: u32,
885 key: PortableResolutionKey,
887}
888
889#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
891struct PortableRelationResolutionKey {
892 relation: u32,
894 key: PortableResolutionKey,
896}
897
898struct BoundGraph {
900 entities: Vec<GraphEntity>,
902 relations: Vec<LogicalRelation>,
904 occurrences: Vec<RelationOccurrence>,
906 coverage: Vec<CoverageRecord>,
908 entity_exports: Vec<EntityResolutionKey>,
910 relation_dependencies: Vec<RelationDependencyKey>,
912}
913
914#[derive(Serialize)]
916struct SnapshotDigestBody<'a> {
917 metadata: &'a DerivedGraphSnapshotMetadata,
919 content: &'a [DerivedSnapshotContent],
921 graph: &'a PortableGraph,
923}
924
925fn snapshot_digest(
927 metadata: &DerivedGraphSnapshotMetadata,
928 content: &[DerivedSnapshotContent],
929 graph: &PortableGraph,
930) -> DbResult<String> {
931 let encoded = serde_json::to_vec(&SnapshotDigestBody {
932 metadata,
933 content,
934 graph,
935 })?;
936 require_limit(
937 "digest body bytes",
938 usize_to_u64(encoded.len())?,
939 MAX_DERIVED_SNAPSHOT_JSON_BYTES,
940 )?;
941 Ok(blake3::hash(&encoded).to_hex().to_string())
942}
943
944fn expected_content(graph: &PortableGraph) -> DbResult<Vec<DerivedSnapshotContent>> {
946 Ok(vec![
947 content("graph_entities", &["entity_selector"], graph.entities.len())?,
948 content(
949 "graph_relations",
950 &[
951 "source_entity",
952 "relation_kind",
953 "resolution",
954 "confidence",
955 "completeness",
956 ],
957 graph.relations.len(),
958 )?,
959 content(
960 "graph_relation_occurrences",
961 &["relation", "file_path", "source_span"],
962 graph.occurrences.len(),
963 )?,
964 content(
965 "graph_coverage",
966 &[
967 "scope",
968 "relation_kind",
969 "state",
970 "covered",
971 "omitted",
972 "reason",
973 "reached_limit",
974 ],
975 graph.coverage.len(),
976 )?,
977 content(
978 "graph_entity_exports+graph_resolution_keys",
979 &["entity", "resolution_domain", "portable_canonical_identity"],
980 graph.entity_exports.len(),
981 )?,
982 content(
983 "graph_relation_dependencies+graph_resolution_keys",
984 &[
985 "relation",
986 "resolution_domain",
987 "portable_canonical_identity",
988 ],
989 graph.relation_dependencies.len(),
990 )?,
991 ])
992}
993
994fn content(table: &str, columns: &[&str], rows: usize) -> DbResult<DerivedSnapshotContent> {
996 Ok(DerivedSnapshotContent {
997 table: table.to_string(),
998 columns: columns.iter().map(|column| (*column).to_string()).collect(),
999 rows: usize_to_u64(rows)?,
1000 })
1001}
1002
1003fn source_state_digest(connection: &Connection) -> DbResult<String> {
1005 let mut statement = connection.prepare(
1006 "SELECT path, kind, extension, language, size_bytes, content_hash
1007 FROM nodes
1008 WHERE exists_now = 1
1009 ORDER BY path",
1010 )?;
1011 let mut rows = statement.query([])?;
1012 let mut hasher = Hasher::new();
1013 hasher.update(b"projectatlas.derived-snapshot.source-state.v1");
1014 let mut row_count = 0_u64;
1015 let mut byte_count = 0_u64;
1016 while let Some(row) = rows.next()? {
1017 row_count = row_count
1018 .checked_add(1)
1019 .ok_or(DbError::DerivedSnapshotInvalid {
1020 reason: "source-state row count overflowed",
1021 })?;
1022 require_limit("source-state rows", row_count, MAX_SOURCE_STATE_ROWS)?;
1023 let fields = [
1024 row.get::<_, String>(0)?,
1025 row.get::<_, String>(1)?,
1026 row.get::<_, Option<String>>(2)?.unwrap_or_default(),
1027 row.get::<_, Option<String>>(3)?.unwrap_or_default(),
1028 row.get::<_, Option<i64>>(4)?
1029 .map_or_else(String::new, |value| value.to_string()),
1030 row.get::<_, Option<String>>(5)?.unwrap_or_default(),
1031 ];
1032 for field in fields {
1033 byte_count = byte_count.checked_add(usize_to_u64(field.len())?).ok_or(
1034 DbError::DerivedSnapshotInvalid {
1035 reason: "source-state byte count overflowed",
1036 },
1037 )?;
1038 require_limit(
1039 "source-state metadata bytes",
1040 byte_count,
1041 MAX_SOURCE_STATE_BYTES,
1042 )?;
1043 hash_field(&mut hasher, field.as_bytes())?;
1044 }
1045 }
1046 hasher.update(&row_count.to_le_bytes());
1047 Ok(hasher.finalize().to_hex().to_string())
1048}
1049
1050fn hash_field(hasher: &mut Hasher, value: &[u8]) -> DbResult<()> {
1052 let length = usize_to_u64(value.len())?;
1053 hasher.update(&length.to_le_bytes());
1054 hasher.update(value);
1055 Ok(())
1056}
1057
1058fn require_private_capture_size(connection: &Connection) -> DbResult<()> {
1060 let page_count = connection.query_row("PRAGMA page_count", [], |row| row.get::<_, u64>(0))?;
1061 let page_size = connection.query_row("PRAGMA page_size", [], |row| row.get::<_, u64>(0))?;
1062 let bytes = page_count
1063 .checked_mul(page_size)
1064 .ok_or(DbError::DerivedSnapshotInvalid {
1065 reason: "private SQLite capture size overflowed",
1066 })?;
1067 require_limit(
1068 "private SQLite capture bytes",
1069 bytes,
1070 MAX_PRIVATE_CAPTURE_BYTES,
1071 )
1072}
1073
1074fn required_index(
1076 indexes: &BTreeMap<[u8; 32], u32>,
1077 key: [u8; 32],
1078 reason: &'static str,
1079) -> DbResult<u32> {
1080 indexes
1081 .get(&key)
1082 .copied()
1083 .ok_or(DbError::DerivedSnapshotInvalid { reason })
1084}
1085
1086fn require_vector_index(index: u32, length: usize, reason: &'static str) -> DbResult<()> {
1088 if usize::try_from(index)
1089 .ok()
1090 .is_some_and(|index| index < length)
1091 {
1092 Ok(())
1093 } else {
1094 invalid(reason)
1095 }
1096}
1097
1098fn require_decode_budget(encoded: &[u8]) -> DbResult<()> {
1100 let mut retained_bytes = 0_u64;
1101 let mut string_bytes = 0_usize;
1102 let mut in_string = false;
1103 let mut escaped = false;
1104 let mut in_primitive = false;
1105
1106 for byte in encoded {
1107 if in_string {
1108 if escaped {
1109 escaped = false;
1110 } else if *byte == b'\\' {
1111 escaped = true;
1112 } else if *byte == b'"' {
1113 in_string = false;
1114 in_primitive = true;
1115 admit_decode_bytes(&mut retained_bytes, usize_to_u64(string_bytes)?)?;
1116 string_bytes = 0;
1117 continue;
1118 }
1119 string_bytes = string_bytes
1120 .checked_add(1)
1121 .ok_or(DbError::DerivedSnapshotInvalid {
1122 reason: "snapshot JSON string size overflowed",
1123 })?;
1124 if string_bytes > MAX_DERIVED_SNAPSHOT_JSON_STRING_BYTES {
1125 return Err(DbError::DerivedSnapshotLimit {
1126 resource: "encoded JSON string bytes",
1127 found: usize_to_u64(string_bytes)?,
1128 maximum: usize_to_u64(MAX_DERIVED_SNAPSHOT_JSON_STRING_BYTES)?,
1129 });
1130 }
1131 continue;
1132 }
1133
1134 match *byte {
1135 b'"' => {
1136 in_string = true;
1137 escaped = false;
1138 in_primitive = false;
1139 admit_decode_bytes(&mut retained_bytes, DERIVED_SNAPSHOT_DECODE_HEADER_BYTES)?;
1140 }
1141 b'{' => {
1142 in_primitive = false;
1143 admit_decode_bytes(&mut retained_bytes, DERIVED_SNAPSHOT_DECODE_OBJECT_BYTES)?;
1144 }
1145 b'[' => {
1146 in_primitive = false;
1147 admit_decode_bytes(&mut retained_bytes, DERIVED_SNAPSHOT_DECODE_HEADER_BYTES)?;
1148 }
1149 b',' | b':' | b'}' | b']' | b' ' | b'\t' | b'\r' | b'\n' => {
1150 in_primitive = false;
1151 }
1152 _ if !in_primitive => {
1153 in_primitive = true;
1154 admit_decode_bytes(&mut retained_bytes, DERIVED_SNAPSHOT_DECODE_PRIMITIVE_BYTES)?;
1155 }
1156 _ => {}
1157 }
1158 }
1159 Ok(())
1160}
1161
1162fn admit_decode_bytes(retained_bytes: &mut u64, bytes: u64) -> DbResult<()> {
1164 *retained_bytes = retained_bytes
1165 .checked_add(bytes)
1166 .ok_or(DbError::DerivedSnapshotInvalid {
1167 reason: "snapshot retained byte count overflowed",
1168 })?;
1169 require_limit(
1170 "decoded retained bytes",
1171 *retained_bytes,
1172 MAX_DERIVED_SNAPSHOT_RETAINED_BYTES,
1173 )
1174}
1175
1176fn indexed<'a, T>(values: &'a [T], index: u32, reason: &'static str) -> DbResult<&'a T> {
1178 values
1179 .get(usize::try_from(index).map_err(|_source| DbError::DerivedSnapshotInvalid { reason })?)
1180 .ok_or(DbError::DerivedSnapshotInvalid { reason })
1181}
1182
1183fn require_limit(resource: &'static str, found: u64, maximum: u64) -> DbResult<()> {
1185 if found <= maximum {
1186 Ok(())
1187 } else {
1188 Err(DbError::DerivedSnapshotLimit {
1189 resource,
1190 found,
1191 maximum,
1192 })
1193 }
1194}
1195
1196fn valid_digest(value: &str) -> bool {
1198 value.len() == BLAKE3_HEX_BYTES
1199 && value
1200 .bytes()
1201 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1202}
1203
1204fn usize_to_u64(value: usize) -> DbResult<u64> {
1206 u64::try_from(value).map_err(|_source| DbError::DerivedSnapshotInvalid {
1207 reason: "snapshot size cannot be represented",
1208 })
1209}
1210
1211fn usize_to_u32(value: usize) -> DbResult<u32> {
1213 u32::try_from(value).map_err(|_source| DbError::DerivedSnapshotLimit {
1214 resource: "portable row indexes",
1215 found: u64::try_from(value).unwrap_or(u64::MAX),
1216 maximum: u64::from(u32::MAX),
1217 })
1218}
1219
1220fn invalid<T>(reason: &'static str) -> DbResult<T> {
1222 Err(DbError::DerivedSnapshotInvalid { reason })
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227 use super::{
1228 DerivedGraphSnapshot, MAX_DERIVED_SNAPSHOT_JSON_STRING_BYTES, expected_content, invalid,
1229 snapshot_digest,
1230 };
1231 use crate::{AtlasStore, DbError};
1232 use projectatlas_core::graph::{
1233 CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
1234 CoverageState, EntityResolutionKey, EntitySelector, GraphEntity, GraphIdentityText,
1235 GraphRelationKind, LogicalRelation, RelationDependencyKey, RelationOccurrence,
1236 RelationResolution, RepositoryFilePath, ResolutionKeyDomain, SourceSpan, SymbolSelector,
1237 };
1238 use projectatlas_core::symbols::{RelationKind, SymbolKind};
1239 use projectatlas_core::telemetry::UsageEvent;
1240 use projectatlas_core::{IndexGeneration, Node, NodeKind, PurposeSource};
1241 use serde_json::json;
1242 use std::error::Error;
1243 use std::fs;
1244 use std::path::Path;
1245
1246 const CONTRACT: &str = "snapshot-test-contract";
1247 const PRIVATE_SENTINEL: &str = "TOP_SECRET_SNAPSHOT_SENTINEL";
1248 const DELETED_PRIVATE_SENTINEL: &str = "DELETED_SNAPSHOT_PAGE_SENTINEL";
1249
1250 fn node(
1251 path: &str,
1252 kind: NodeKind,
1253 parent_path: Option<&str>,
1254 content_hash: Option<&str>,
1255 ) -> Node {
1256 Node {
1257 path: path.to_string(),
1258 kind,
1259 parent_path: parent_path.map(str::to_string),
1260 extension: (kind == NodeKind::File).then(|| ".rs".to_string()),
1261 language: (kind == NodeKind::File).then(|| "rust".to_string()),
1262 size_bytes: (kind == NodeKind::File).then_some(32),
1263 mtime_ns: (kind == NodeKind::File).then_some(1),
1264 content_hash: content_hash.map(str::to_string),
1265 }
1266 }
1267
1268 fn open_store(root: &Path) -> Result<AtlasStore, Box<dyn Error>> {
1269 let atlas = root.join(".projectatlas");
1270 fs::create_dir_all(&atlas)?;
1271 Ok(AtlasStore::open_for_project(
1272 &atlas.join("projectatlas.db"),
1273 root,
1274 )?)
1275 }
1276
1277 fn publish_fixture(
1278 store: &mut AtlasStore,
1279 content_hash: &str,
1280 with_graph: bool,
1281 ) -> Result<(), Box<dyn Error>> {
1282 let project = store
1283 .project_instance_id()?
1284 .ok_or("fixture project identity is missing")?;
1285 let generation = IndexGeneration::new(1);
1286 let mut publication = store.begin_index_publication(CONTRACT)?;
1287 publication.begin_scan_replacement()?;
1288 publication.upsert_scan_node_batch(&[
1289 node(".", NodeKind::Folder, None, None),
1290 node("src", NodeKind::Folder, Some("."), None),
1291 node(
1292 "src/lib.rs",
1293 NodeKind::File,
1294 Some("src"),
1295 Some(content_hash),
1296 ),
1297 ])?;
1298 publication.finish_scan_replacement()?;
1299 if with_graph {
1300 let project_entity = GraphEntity::new(project, EntitySelector::Project, generation)?;
1301 let file = GraphEntity::new(
1302 project,
1303 EntitySelector::File {
1304 path: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
1305 },
1306 generation,
1307 )?;
1308 let symbol = GraphEntity::new(
1309 project,
1310 EntitySelector::Symbol {
1311 symbol: SymbolSelector {
1312 file: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
1313 name: GraphIdentityText::new("answer")?,
1314 kind: SymbolKind::Function,
1315 parent: None,
1316 signature: GraphIdentityText::new("fn answer() -> u32")?,
1317 },
1318 },
1319 generation,
1320 )?;
1321 let relation = LogicalRelation::new(
1322 &file,
1323 GraphRelationKind::Legacy(RelationKind::Calls),
1324 RelationResolution::resolved(&symbol)?,
1325 ConfidenceClass::Exact,
1326 Completeness::Complete,
1327 generation,
1328 )?;
1329 let occurrence = RelationOccurrence::new(
1330 &relation,
1331 RepositoryFilePath::new(Path::new("src/lib.rs"))?,
1332 SourceSpan::new(1, 0, 1, 6)?,
1333 generation,
1334 )?;
1335 let coverage = CoverageRecord::new(
1336 CoverageScope::Project,
1337 None,
1338 CoverageState::Complete,
1339 1,
1340 0,
1341 generation,
1342 None,
1343 None,
1344 )?;
1345 let key = CanonicalResolutionKey::new(
1346 project,
1347 ResolutionKeyDomain::Declaration,
1348 &GraphIdentityText::new("tree-sitter")?,
1349 &GraphIdentityText::new("rust")?,
1350 None,
1351 Some(&GraphIdentityText::new("crate")?),
1352 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
1353 &GraphIdentityText::new("answer")?,
1354 );
1355 publication.replace_repository_graph_with_resolution_keys(
1356 project,
1357 &[project_entity, file, symbol.clone()],
1358 std::slice::from_ref(&relation),
1359 &[occurrence],
1360 &[coverage],
1361 &[EntityResolutionKey::new(symbol.key().clone(), key.clone())?],
1362 &[RelationDependencyKey::new(relation.key().clone(), key)?],
1363 )?;
1364 } else {
1365 publication.replace_repository_graph_with_resolution_keys(
1366 project,
1367 &[],
1368 &[],
1369 &[],
1370 &[],
1371 &[],
1372 &[],
1373 )?;
1374 }
1375 publication.complete()?;
1376 Ok(())
1377 }
1378
1379 fn seed_private_state(store: &AtlasStore) -> Result<(), Box<dyn Error>> {
1380 store.set_purpose("src/lib.rs", PRIVATE_SENTINEL, PurposeSource::Agent)?;
1381 store.connection.execute(
1382 "INSERT INTO health_resolutions(
1383 finding_id, category, path, related_path, rationale
1384 ) VALUES('snapshot-private-health', 'snapshot-private', 'src/lib.rs', NULL, ?1)",
1385 [PRIVATE_SENTINEL],
1386 )?;
1387 let usage = serde_json::from_value::<UsageEvent>(json!({
1388 "session_id": "snapshot-private",
1389 "command": "summary",
1390 "query": PRIVATE_SENTINEL
1391 }))?;
1392 store.record_usage(&usage)?;
1393 store.connection.execute(
1394 "INSERT INTO metadata(key, value) VALUES('snapshot.private.setting', ?1)",
1395 [PRIVATE_SENTINEL],
1396 )?;
1397 store.connection.execute_batch(
1398 "CREATE TABLE future_memory_atlas(secret TEXT);
1399 INSERT INTO future_memory_atlas(secret)
1400 VALUES('TOP_SECRET_SNAPSHOT_SENTINEL');",
1401 )?;
1402 store.connection.execute(
1403 "INSERT INTO metadata(key, value) VALUES('snapshot.deleted.secret', ?1)",
1404 [DELETED_PRIVATE_SENTINEL],
1405 )?;
1406 store.connection.execute(
1407 "DELETE FROM metadata WHERE key = 'snapshot.deleted.secret'",
1408 [],
1409 )?;
1410 Ok(())
1411 }
1412
1413 #[test]
1414 fn malformed_snapshot_json_is_rejected_before_use() {
1415 let result = DerivedGraphSnapshot::from_json(br#"{"format_version":1}"#);
1416 assert!(result.is_err());
1417 assert!(invalid::<()>("fixture").is_err());
1418 }
1419
1420 #[test]
1421 fn snapshot_json_decode_budget_rejects_large_shapes_before_deserialization() {
1422 let mut wide = String::from("[");
1423 for index in 0..2_100_000 {
1424 if index != 0 {
1425 wide.push(',');
1426 }
1427 wide.push_str("{}");
1428 }
1429 wide.push(']');
1430 assert!(matches!(
1431 DerivedGraphSnapshot::from_json(wide.as_bytes()),
1432 Err(DbError::DerivedSnapshotLimit {
1433 resource: "decoded retained bytes",
1434 ..
1435 })
1436 ));
1437
1438 let oversized_string = format!(
1439 r#"{{"value":"{}"}}"#,
1440 "x".repeat(MAX_DERIVED_SNAPSHOT_JSON_STRING_BYTES + 1)
1441 );
1442 assert!(matches!(
1443 DerivedGraphSnapshot::from_json(oversized_string.as_bytes()),
1444 Err(DbError::DerivedSnapshotLimit {
1445 resource: "encoded JSON string bytes",
1446 ..
1447 })
1448 ));
1449 }
1450
1451 #[test]
1452 fn large_valid_snapshot_round_trips_without_charging_json_delimiters()
1453 -> Result<(), Box<dyn Error>> {
1454 let source_root = tempfile::tempdir()?;
1455 let mut source = open_store(source_root.path())?;
1456 publish_fixture(&mut source, "large-round-trip", true)?;
1457 let mut snapshot = source.export_derived_graph_snapshot()?;
1458 let coverage = snapshot
1459 .graph
1460 .coverage
1461 .first()
1462 .ok_or("fixture coverage is missing")?
1463 .clone();
1464 snapshot.graph.coverage = vec![coverage; 40_000];
1465 snapshot.content = expected_content(&snapshot.graph)?;
1466 snapshot.digest = snapshot_digest(&snapshot.metadata, &snapshot.content, &snapshot.graph)?;
1467
1468 let encoded = snapshot.to_json()?;
1469 let decoded = DerivedGraphSnapshot::from_json(&encoded)?;
1470 if decoded != snapshot {
1471 return Err("large valid snapshot changed during round trip".into());
1472 }
1473 Ok(())
1474 }
1475
1476 #[test]
1477 #[allow(clippy::panic_in_result_fn)]
1478 fn derived_snapshot_excludes_private_state_and_rebinds_atomically() -> Result<(), Box<dyn Error>>
1479 {
1480 let source_root = tempfile::tempdir()?;
1481 let destination_root = tempfile::tempdir()?;
1482 let mut source = open_store(source_root.path())?;
1483 let mut destination = open_store(destination_root.path())?;
1484 publish_fixture(&mut source, "same-content", true)?;
1485 publish_fixture(&mut destination, "same-content", false)?;
1486 seed_private_state(&source)?;
1487 seed_private_state(&destination)?;
1488
1489 let source_identity = source
1490 .project_instance_id()?
1491 .ok_or("source identity is missing")?;
1492 let destination_identity = destination
1493 .project_instance_id()?
1494 .ok_or("destination identity is missing")?;
1495 assert_ne!(source_identity, destination_identity);
1496
1497 let exported = source.export_derived_graph_snapshot()?;
1498 let encoded = exported.to_json()?;
1499 let encoded_text = String::from_utf8(encoded.clone())?;
1500 assert!(!encoded_text.contains(PRIVATE_SENTINEL));
1501 assert!(!encoded_text.contains(DELETED_PRIVATE_SENTINEL));
1502 assert!(!encoded_text.contains(&source_identity.to_string()));
1503 let escaped_source_root =
1504 serde_json::to_string(source_root.path().to_string_lossy().as_ref())?;
1505 assert!(!encoded_text.contains(escaped_source_root.trim_matches('"')));
1506 assert_eq!(
1507 exported.content().iter().map(|row| row.rows).sum::<u64>(),
1508 8
1509 );
1510
1511 let decoded = DerivedGraphSnapshot::from_json(&encoded)?;
1512 let report = destination.import_derived_graph_snapshot(&decoded)?;
1513 assert_eq!(report.previous_generation, IndexGeneration::new(1));
1514 assert_eq!(report.published_generation, IndexGeneration::new(2));
1515 assert_eq!(
1516 destination.project_instance_id()?,
1517 Some(destination_identity)
1518 );
1519 let entities = destination.repository_graph_entities_by_path(
1520 destination_identity,
1521 &projectatlas_core::graph::RepositoryNodePath::new(Path::new("src/lib.rs"))?,
1522 10,
1523 )?;
1524 assert_eq!(entities.rows.len(), 2);
1525 let indexed = destination.load_nodes_by_paths(&["src/lib.rs".to_string()])?;
1526 assert_eq!(
1527 indexed[0].purpose.purpose.as_deref(),
1528 Some(PRIVATE_SENTINEL)
1529 );
1530 assert_eq!(
1531 destination.connection.query_row(
1532 "SELECT rationale FROM health_resolutions
1533 WHERE finding_id = 'snapshot-private-health'",
1534 [],
1535 |row| row.get::<_, String>(0),
1536 )?,
1537 PRIVATE_SENTINEL
1538 );
1539 assert_eq!(
1540 destination.connection.query_row(
1541 "SELECT value FROM metadata WHERE key = 'snapshot.private.setting'",
1542 [],
1543 |row| row.get::<_, String>(0),
1544 )?,
1545 PRIVATE_SENTINEL
1546 );
1547 assert_eq!(
1548 destination.connection.query_row(
1549 "SELECT query FROM usage_events ORDER BY id DESC LIMIT 1",
1550 [],
1551 |row| row.get::<_, String>(0),
1552 )?,
1553 PRIVATE_SENTINEL
1554 );
1555 assert_eq!(
1556 destination.connection.query_row(
1557 "SELECT secret FROM future_memory_atlas",
1558 [],
1559 |row| row.get::<_, String>(0),
1560 )?,
1561 PRIVATE_SENTINEL
1562 );
1563 Ok(())
1564 }
1565
1566 #[test]
1567 #[allow(clippy::panic_in_result_fn)]
1568 fn derived_snapshot_rejects_tampering_and_source_mismatch_without_publication()
1569 -> Result<(), Box<dyn Error>> {
1570 let source_root = tempfile::tempdir()?;
1571 let destination_root = tempfile::tempdir()?;
1572 let mut source = open_store(source_root.path())?;
1573 let mut destination = open_store(destination_root.path())?;
1574 publish_fixture(&mut source, "source-content", true)?;
1575 publish_fixture(&mut destination, "different-content", false)?;
1576
1577 let snapshot = source.export_derived_graph_snapshot()?;
1578 let mut tampered = snapshot;
1579 tampered.metadata.source_state_digest =
1580 "0".repeat(tampered.metadata.source_state_digest.len());
1581 assert!(tampered.to_json().is_err());
1582
1583 let mut internally_consistent = tampered;
1584 internally_consistent.digest = snapshot_digest(
1585 &internally_consistent.metadata,
1586 &internally_consistent.content,
1587 &internally_consistent.graph,
1588 )?;
1589 let before = destination
1590 .index_publication()?
1591 .ok_or("publication missing")?;
1592 assert!(
1593 destination
1594 .import_derived_graph_snapshot(&internally_consistent)
1595 .is_err()
1596 );
1597 assert_eq!(
1598 destination
1599 .index_publication()?
1600 .ok_or("publication missing")?,
1601 before
1602 );
1603 assert_eq!(
1604 destination.repository_graph_generation()?,
1605 Some(before.generation)
1606 );
1607 Ok(())
1608 }
1609
1610 #[test]
1611 #[allow(clippy::panic_in_result_fn)]
1612 fn derived_snapshot_rechecks_source_state_inside_publication_transaction()
1613 -> Result<(), Box<dyn Error>> {
1614 let source_root = tempfile::tempdir()?;
1615 let destination_root = tempfile::tempdir()?;
1616 let mut source = open_store(source_root.path())?;
1617 let mut destination = open_store(destination_root.path())?;
1618 publish_fixture(&mut source, "same-content", true)?;
1619 publish_fixture(&mut destination, "same-content", false)?;
1620 let mut concurrent = open_store(destination_root.path())?;
1621 let snapshot = source.export_derived_graph_snapshot()?;
1622 let before = destination
1623 .index_publication()?
1624 .ok_or("destination publication is missing")?;
1625
1626 let result =
1627 destination.import_derived_graph_snapshot_with_prepublication(&snapshot, || {
1628 concurrent.upsert_scan_nodes(&[node(
1629 "src/lib.rs",
1630 NodeKind::File,
1631 Some("src"),
1632 Some("concurrent-content"),
1633 )])
1634 });
1635 assert!(matches!(
1636 result,
1637 Err(DbError::DerivedSnapshotInvalid {
1638 reason: "destination source state does not match the snapshot"
1639 })
1640 ));
1641 assert_eq!(
1642 destination
1643 .index_publication()?
1644 .ok_or("destination publication is missing")?,
1645 before
1646 );
1647 assert_eq!(
1648 destination.repository_graph_generation()?,
1649 Some(before.generation)
1650 );
1651 assert_eq!(
1652 destination.load_nodes_by_paths(&["src/lib.rs".to_string()])?[0]
1653 .node
1654 .content_hash
1655 .as_deref(),
1656 Some("concurrent-content")
1657 );
1658 Ok(())
1659 }
1660}