1use std::collections::BTreeMap;
2use std::error::Error;
3use std::ffi::OsString;
4use std::fmt;
5use std::fs::{self, File, Metadata};
6use std::io::Read;
7use std::path::{Path, PathBuf};
8use std::time::UNIX_EPOCH;
9
10use type_bridge_contract::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticCode};
11use type_bridge_contract::schema::{
12 DocumentFingerprint, DocumentId, MAX_DOCUMENT_ID_BYTES, SchemaDiagnostic, SchemaDiagnostics,
13 SchemaDocumentSetFingerprint,
14};
15use unicode_casefold::UnicodeCaseFold;
16use unicode_normalization::UnicodeNormalization;
17
18use crate::schema_set::{SchemaDiscoveryVersion, SchemaSetManifestDocument};
19use crate::source_pattern::{
20 PatternSegment, ValidatedSourcePattern as PortablePattern, validate_source_pattern,
21};
22use crate::{SchemaDocumentSet, SchemaParseLimits};
23
24pub const DEFAULT_MAX_SOURCE_PATTERNS: usize = 4_096;
26pub const DEFAULT_MAX_SOURCE_PATTERN_BYTES: usize = MAX_DOCUMENT_ID_BYTES;
28pub const DEFAULT_MAX_DISCOVERY_ENTRIES: usize = 65_536;
30pub const DEFAULT_MAX_DISCOVERY_DEPTH: usize = 64;
32
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub struct SchemaSourceServiceError;
36
37impl fmt::Display for SchemaSourceServiceError {
38 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39 formatter.write_str("schema source observation is unavailable")
40 }
41}
42
43impl Error for SchemaSourceServiceError {}
44
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum SchemaSourceKind {
48 File,
50 Directory,
52 Symlink,
54 Other,
56}
57
58#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
60pub struct SchemaSourceIdentity(String);
61
62impl SchemaSourceIdentity {
63 pub fn new(value: impl Into<String>) -> Result<Self, SchemaSourceServiceError> {
65 let value = value.into();
66 if value.is_empty() || value.len() > MAX_DOCUMENT_ID_BYTES {
67 return Err(SchemaSourceServiceError);
68 }
69 Ok(Self(value))
70 }
71}
72
73#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct SchemaSourceRevision(String);
76
77impl SchemaSourceRevision {
78 pub fn new(value: impl Into<String>) -> Result<Self, SchemaSourceServiceError> {
80 let value = value.into();
81 if value.is_empty() || value.len() > MAX_DOCUMENT_ID_BYTES {
82 return Err(SchemaSourceServiceError);
83 }
84 Ok(Self(value))
85 }
86}
87
88#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct SchemaSourceObservation {
91 identity: SchemaSourceIdentity,
92 revision: SchemaSourceRevision,
93 len: u64,
94 kind: SchemaSourceKind,
95}
96
97impl SchemaSourceObservation {
98 #[must_use]
100 pub const fn new(
101 identity: SchemaSourceIdentity,
102 revision: SchemaSourceRevision,
103 len: u64,
104 kind: SchemaSourceKind,
105 ) -> Self {
106 Self {
107 identity,
108 revision,
109 len,
110 kind,
111 }
112 }
113
114 #[must_use]
116 pub const fn identity(&self) -> &SchemaSourceIdentity {
117 &self.identity
118 }
119
120 #[must_use]
122 pub const fn revision(&self) -> &SchemaSourceRevision {
123 &self.revision
124 }
125
126 #[must_use]
128 pub const fn len(&self) -> u64 {
129 self.len
130 }
131
132 #[must_use]
134 pub const fn is_empty(&self) -> bool {
135 self.len == 0
136 }
137
138 #[must_use]
140 pub const fn kind(&self) -> SchemaSourceKind {
141 self.kind
142 }
143}
144
145#[derive(Clone, Debug, Eq, PartialEq)]
147pub struct SchemaSourceCapture {
148 bytes: Vec<u8>,
149 before: SchemaSourceObservation,
150 after: SchemaSourceObservation,
151}
152
153impl SchemaSourceCapture {
154 #[must_use]
156 pub const fn new(
157 bytes: Vec<u8>,
158 before: SchemaSourceObservation,
159 after: SchemaSourceObservation,
160 ) -> Self {
161 Self {
162 bytes,
163 before,
164 after,
165 }
166 }
167
168 #[must_use]
170 pub fn bytes(&self) -> &[u8] {
171 &self.bytes
172 }
173
174 #[must_use]
176 pub const fn before(&self) -> &SchemaSourceObservation {
177 &self.before
178 }
179
180 #[must_use]
182 pub const fn after(&self) -> &SchemaSourceObservation {
183 &self.after
184 }
185}
186
187pub trait SchemaSourceService {
193 fn canonicalize(&self, path: &Path) -> Result<PathBuf, SchemaSourceServiceError>;
195
196 fn metadata(&self, path: &Path) -> Result<SchemaSourceObservation, SchemaSourceServiceError>;
198
199 fn symlink_metadata(
201 &self,
202 path: &Path,
203 ) -> Result<SchemaSourceObservation, SchemaSourceServiceError>;
204
205 fn read_directory_names(&self, path: &Path) -> Result<Vec<OsString>, SchemaSourceServiceError>;
207
208 fn capture_file(
210 &self,
211 path: &Path,
212 maximum_bytes: usize,
213 ) -> Result<SchemaSourceCapture, SchemaSourceServiceError>;
214}
215
216#[derive(Clone, Copy, Debug, Default)]
218pub struct SystemSchemaSourceService;
219
220impl SchemaSourceService for SystemSchemaSourceService {
221 fn canonicalize(&self, path: &Path) -> Result<PathBuf, SchemaSourceServiceError> {
222 fs::canonicalize(path).map_err(|_| SchemaSourceServiceError)
223 }
224
225 fn metadata(&self, path: &Path) -> Result<SchemaSourceObservation, SchemaSourceServiceError> {
226 observation(
227 &fs::metadata(path).map_err(|_| SchemaSourceServiceError)?,
228 path,
229 )
230 }
231
232 fn symlink_metadata(
233 &self,
234 path: &Path,
235 ) -> Result<SchemaSourceObservation, SchemaSourceServiceError> {
236 observation(
237 &fs::symlink_metadata(path).map_err(|_| SchemaSourceServiceError)?,
238 path,
239 )
240 }
241
242 fn read_directory_names(&self, path: &Path) -> Result<Vec<OsString>, SchemaSourceServiceError> {
243 let mut names = fs::read_dir(path)
244 .map_err(|_| SchemaSourceServiceError)?
245 .map(|entry| {
246 entry
247 .map(|entry| entry.file_name())
248 .map_err(|_| SchemaSourceServiceError)
249 })
250 .collect::<Result<Vec<_>, _>>()?;
251 names.sort();
252 Ok(names)
253 }
254
255 fn capture_file(
256 &self,
257 path: &Path,
258 maximum_bytes: usize,
259 ) -> Result<SchemaSourceCapture, SchemaSourceServiceError> {
260 let mut file = File::open(path).map_err(|_| SchemaSourceServiceError)?;
261 let before = observation(
262 &file.metadata().map_err(|_| SchemaSourceServiceError)?,
263 path,
264 )?;
265 let read_limit = u64::try_from(maximum_bytes)
266 .unwrap_or(u64::MAX)
267 .saturating_add(1);
268 let mut bytes = Vec::new();
269 (&mut file)
270 .take(read_limit)
271 .read_to_end(&mut bytes)
272 .map_err(|_| SchemaSourceServiceError)?;
273 let after = observation(
274 &file.metadata().map_err(|_| SchemaSourceServiceError)?,
275 path,
276 )?;
277 Ok(SchemaSourceCapture::new(bytes, before, after))
278 }
279}
280
281#[derive(Clone, Copy, Debug, Eq, PartialEq)]
283pub struct SchemaDiscoveryLimits {
284 parse_limits: SchemaParseLimits,
285 max_patterns: usize,
286 max_pattern_bytes: usize,
287 max_entries: usize,
288 max_depth: usize,
289}
290
291impl SchemaDiscoveryLimits {
292 #[must_use]
294 pub const fn new(
295 parse_limits: SchemaParseLimits,
296 max_patterns: usize,
297 max_pattern_bytes: usize,
298 max_entries: usize,
299 max_depth: usize,
300 ) -> Self {
301 Self {
302 parse_limits,
303 max_patterns,
304 max_pattern_bytes,
305 max_entries,
306 max_depth,
307 }
308 }
309
310 #[must_use]
312 pub const fn parse_limits(self) -> SchemaParseLimits {
313 self.parse_limits
314 }
315
316 #[must_use]
318 pub const fn max_patterns(self) -> usize {
319 self.max_patterns
320 }
321
322 #[must_use]
324 pub const fn max_pattern_bytes(self) -> usize {
325 self.max_pattern_bytes
326 }
327
328 #[must_use]
330 pub const fn max_entries(self) -> usize {
331 self.max_entries
332 }
333
334 #[must_use]
336 pub const fn max_depth(self) -> usize {
337 self.max_depth
338 }
339}
340
341impl Default for SchemaDiscoveryLimits {
342 fn default() -> Self {
343 Self::new(
344 SchemaParseLimits::default(),
345 DEFAULT_MAX_SOURCE_PATTERNS,
346 DEFAULT_MAX_SOURCE_PATTERN_BYTES,
347 DEFAULT_MAX_DISCOVERY_ENTRIES,
348 DEFAULT_MAX_DISCOVERY_DEPTH,
349 )
350 }
351}
352
353#[derive(Clone, Debug, Eq, PartialEq)]
355pub struct SchemaPatternDiscoverySnapshot {
356 root: PathBuf,
357 manifest: PathBuf,
358 documents: SchemaDocumentSet,
359}
360
361impl SchemaPatternDiscoverySnapshot {
362 #[must_use]
364 pub fn root(&self) -> &Path {
365 &self.root
366 }
367
368 #[must_use]
370 pub fn manifest(&self) -> &Path {
371 &self.manifest
372 }
373
374 #[must_use]
376 pub const fn documents(&self) -> &SchemaDocumentSet {
377 &self.documents
378 }
379
380 #[must_use]
382 pub fn into_documents(self) -> SchemaDocumentSet {
383 self.documents
384 }
385}
386
387#[derive(Clone, Debug, Eq, PartialEq)]
389pub struct SchemaSourceEvidence {
390 path: DocumentId,
391 fingerprint: DocumentFingerprint,
392}
393
394impl SchemaSourceEvidence {
395 #[must_use]
397 pub const fn path(&self) -> &DocumentId {
398 &self.path
399 }
400
401 #[must_use]
403 pub const fn fingerprint(&self) -> &DocumentFingerprint {
404 &self.fingerprint
405 }
406}
407
408#[derive(Clone, Debug, Eq, PartialEq)]
410pub struct SchemaDiscoveryEvidence {
411 discovery_version: SchemaDiscoveryVersion,
412 manifest_fingerprint: DocumentFingerprint,
413 sources: Vec<SchemaSourceEvidence>,
414 document_set_fingerprint: SchemaDocumentSetFingerprint,
415}
416
417impl SchemaDiscoveryEvidence {
418 #[must_use]
420 pub const fn discovery_version(&self) -> &SchemaDiscoveryVersion {
421 &self.discovery_version
422 }
423
424 #[must_use]
426 pub const fn manifest_fingerprint(&self) -> &DocumentFingerprint {
427 &self.manifest_fingerprint
428 }
429
430 #[must_use]
432 pub fn sources(&self) -> &[SchemaSourceEvidence] {
433 &self.sources
434 }
435
436 #[must_use]
438 pub const fn document_set_fingerprint(&self) -> &SchemaDocumentSetFingerprint {
439 &self.document_set_fingerprint
440 }
441}
442
443#[derive(Clone, Debug, Eq, PartialEq)]
445pub struct SchemaDiscoverySnapshot {
446 root: PathBuf,
447 manifest: SchemaSetManifestDocument,
448 documents: SchemaDocumentSet,
449 discovery_version: SchemaDiscoveryVersion,
450 evidence: SchemaDiscoveryEvidence,
451}
452
453impl SchemaDiscoverySnapshot {
454 #[must_use]
456 pub fn root(&self) -> &Path {
457 &self.root
458 }
459
460 #[must_use]
462 pub const fn manifest(&self) -> &SchemaSetManifestDocument {
463 &self.manifest
464 }
465
466 #[must_use]
468 pub const fn documents(&self) -> &SchemaDocumentSet {
469 &self.documents
470 }
471
472 #[must_use]
474 pub const fn discovery_version(&self) -> &SchemaDiscoveryVersion {
475 &self.discovery_version
476 }
477
478 #[must_use]
480 pub const fn evidence(&self) -> &SchemaDiscoveryEvidence {
481 &self.evidence
482 }
483
484 #[must_use]
486 pub fn into_documents(self) -> SchemaDocumentSet {
487 self.documents
488 }
489}
490
491pub fn discover_schema_documents<I, S>(
493 manifest: impl AsRef<Path>,
494 patterns: I,
495) -> Result<SchemaPatternDiscoverySnapshot, SchemaDiagnostics>
496where
497 I: IntoIterator<Item = S>,
498 S: Into<String>,
499{
500 discover_schema_documents_with_limits(manifest, patterns, SchemaDiscoveryLimits::default())
501}
502
503pub fn discover_schema_documents_with_limits<I, S>(
505 manifest: impl AsRef<Path>,
506 patterns: I,
507 limits: SchemaDiscoveryLimits,
508) -> Result<SchemaPatternDiscoverySnapshot, SchemaDiagnostics>
509where
510 I: IntoIterator<Item = S>,
511 S: Into<String>,
512{
513 discover_schema_documents_with_source(manifest, patterns, limits, &SystemSchemaSourceService)
514}
515
516fn discover_schema_documents_with_source<I, P, S>(
517 manifest: impl AsRef<Path>,
518 patterns: I,
519 limits: SchemaDiscoveryLimits,
520 source: &S,
521) -> Result<SchemaPatternDiscoverySnapshot, SchemaDiagnostics>
522where
523 I: IntoIterator<Item = P>,
524 P: Into<String>,
525 S: SchemaSourceService + ?Sized,
526{
527 let manifest_input = manifest.as_ref();
528 let manifest_parent = manifest_input.parent().ok_or_else(|| {
529 failure(
530 DiagnosticCategory::InvalidContract,
531 "schema_manifest_has_no_root",
532 "schema-set manifest must have a containing directory",
533 [("manifest", display_path(manifest_input))],
534 )
535 })?;
536 let root = canonicalize_path(source, manifest_parent, "schema_root_unavailable")?;
537 let canonical_manifest =
538 canonicalize_path(source, manifest_input, "schema_manifest_unavailable")?;
539 if !canonical_manifest.starts_with(&root) {
540 return Err(failure(
541 DiagnosticCategory::InvalidContract,
542 "schema_manifest_root_escape",
543 "schema-set manifest resolves outside its schema root",
544 [("manifest", display_path(manifest_input))],
545 ));
546 }
547 let manifest_metadata = metadata(source, &canonical_manifest, "schema_manifest_unavailable")?;
548 if manifest_metadata.kind != SchemaSourceKind::File {
549 return Err(failure(
550 DiagnosticCategory::InvalidContract,
551 "schema_manifest_not_regular",
552 "schema-set manifest must resolve to a regular file",
553 [("manifest", display_path(manifest_input))],
554 ));
555 }
556 let manifest_state = PathState::capture(source, manifest_input, &canonical_manifest)?;
557
558 let patterns = validate_patterns(patterns, limits)?;
559 if patterns.is_empty() {
560 return Err(failure(
561 DiagnosticCategory::InvalidContract,
562 "empty_schema_source_patterns",
563 "schema-set manifest must select at least one source pattern",
564 std::iter::empty::<(&str, String)>(),
565 ));
566 }
567
568 let selected = select_sources(source, &root, &canonical_manifest, &patterns, limits)?;
569 let captured = capture_sources(source, &selected, limits.parse_limits())?;
570
571 if !manifest_state.matches(source, manifest_input, &canonical_manifest) {
572 return Err(snapshot_changed(
573 "schema-set manifest changed during source discovery",
574 ));
575 }
576 let reselected = select_sources(source, &root, &canonical_manifest, &patterns, limits)
577 .map_err(|_| snapshot_changed("schema source selection changed during discovery"))?;
578 reject_snapshot_change(&selected, &reselected)?;
579 if !manifest_state.matches(source, manifest_input, &canonical_manifest) {
580 return Err(snapshot_changed(
581 "schema-set manifest changed while source selection was revalidated",
582 ));
583 }
584 revalidate_captured_sources(source, &reselected, &captured, limits.parse_limits())?;
585
586 let documents = SchemaDocumentSet::parse_with_limits(captured, limits.parse_limits())?;
587 Ok(SchemaPatternDiscoverySnapshot {
588 root,
589 manifest: canonical_manifest,
590 documents,
591 })
592}
593
594pub fn load_schema_set(
596 manifest: impl AsRef<Path>,
597) -> Result<SchemaDiscoverySnapshot, SchemaDiagnostics> {
598 load_schema_set_with_limits(manifest, SchemaDiscoveryLimits::default())
599}
600
601pub fn load_schema_set_with_limits(
603 manifest: impl AsRef<Path>,
604 limits: SchemaDiscoveryLimits,
605) -> Result<SchemaDiscoverySnapshot, SchemaDiagnostics> {
606 load_schema_set_with_source(manifest, &SystemSchemaSourceService, limits)
607}
608
609pub fn load_schema_set_with_source<S>(
611 manifest: impl AsRef<Path>,
612 source: &S,
613 limits: SchemaDiscoveryLimits,
614) -> Result<SchemaDiscoverySnapshot, SchemaDiagnostics>
615where
616 S: SchemaSourceService + ?Sized,
617{
618 let manifest_input = manifest.as_ref();
619 let manifest_parent = manifest_input.parent().ok_or_else(|| {
620 failure(
621 DiagnosticCategory::InvalidContract,
622 "schema_manifest_has_no_root",
623 "schema-set manifest must have a containing directory",
624 [("manifest", display_path(manifest_input))],
625 )
626 })?;
627 let root = canonicalize_path(source, manifest_parent, "schema_root_unavailable")?;
628 let canonical_manifest =
629 canonicalize_path(source, manifest_input, "schema_manifest_unavailable")?;
630 if !canonical_manifest.starts_with(&root) {
631 return Err(failure(
632 DiagnosticCategory::InvalidContract,
633 "schema_manifest_root_escape",
634 "schema-set manifest resolves outside its schema root",
635 [("manifest", display_path(manifest_input))],
636 ));
637 }
638 let manifest_metadata = metadata(source, &canonical_manifest, "schema_manifest_unavailable")?;
639 if manifest_metadata.kind != SchemaSourceKind::File {
640 return Err(failure(
641 DiagnosticCategory::InvalidContract,
642 "schema_manifest_not_regular",
643 "schema-set manifest must resolve to a regular file",
644 [("manifest", display_path(manifest_input))],
645 ));
646 }
647 let manifest_state = PathState::capture(source, manifest_input, &canonical_manifest)?;
648 let manifest_source = capture_manifest_source(
649 source,
650 manifest_input,
651 &canonical_manifest,
652 &manifest_state,
653 limits.parse_limits(),
654 )?;
655 if !manifest_state.matches(source, manifest_input, &canonical_manifest) {
656 return Err(snapshot_changed(
657 "schema-set manifest changed while it was parsed",
658 ));
659 }
660 let manifest_document = SchemaSetManifestDocument::parse(
661 canonical_manifest.clone(),
662 manifest_source,
663 limits.parse_limits(),
664 )?;
665 let patterns = validate_patterns(manifest_document.sources().iter().cloned(), limits)?;
666
667 let selected = select_sources(source, &root, &canonical_manifest, &patterns, limits)?;
668 let captured = capture_sources(source, &selected, limits.parse_limits())?;
669 if !manifest_state.matches(source, manifest_input, &canonical_manifest) {
670 return Err(snapshot_changed(
671 "schema-set manifest changed during source discovery",
672 ));
673 }
674 let reselected = select_sources(source, &root, &canonical_manifest, &patterns, limits)
675 .map_err(|_| snapshot_changed("schema source selection changed during discovery"))?;
676 reject_snapshot_change(&selected, &reselected)?;
677 if !manifest_state.matches(source, manifest_input, &canonical_manifest) {
678 return Err(snapshot_changed(
679 "schema-set manifest changed while source selection was revalidated",
680 ));
681 }
682 revalidate_captured_sources(source, &reselected, &captured, limits.parse_limits())?;
683
684 let mut documents = SchemaDocumentSet::parse_with_limits(captured, limits.parse_limits())?;
685 let document_set_fingerprint = documents.fingerprint()?;
686 let sources = documents
687 .iter()
688 .map(|(path, document)| SchemaSourceEvidence {
689 path: path.clone(),
690 fingerprint: document.fingerprint().clone(),
691 })
692 .collect();
693 let discovery_version = SchemaDiscoveryVersion;
694 let evidence = SchemaDiscoveryEvidence {
695 discovery_version: discovery_version.clone(),
696 manifest_fingerprint: manifest_document.fingerprint().clone(),
697 sources,
698 document_set_fingerprint,
699 };
700 documents.attach_manifest(manifest_document.clone());
701 Ok(SchemaDiscoverySnapshot {
702 root,
703 manifest: manifest_document,
704 documents,
705 discovery_version,
706 evidence,
707 })
708}
709
710fn validate_patterns<I, S>(
711 patterns: I,
712 limits: SchemaDiscoveryLimits,
713) -> Result<Vec<PortablePattern>, SchemaDiagnostics>
714where
715 I: IntoIterator<Item = S>,
716 S: Into<String>,
717{
718 let mut validated = Vec::new();
719 for value in patterns {
720 if validated.len() >= limits.max_patterns() {
721 return Err(resource_failure(
722 "schema_source_pattern_count_limit",
723 "schema source pattern count exceeds its configured limit",
724 [("maximum", limits.max_patterns().to_string())],
725 ));
726 }
727 validated.push(
728 validate_source_pattern(value.into(), limits.max_pattern_bytes()).map_err(
729 |diagnostic| SchemaDiagnostics::one(SchemaDiagnostic::new(diagnostic, None)),
730 )?,
731 );
732 }
733 Ok(validated)
734}
735
736#[derive(Clone, Debug, Eq, PartialEq)]
737enum CandidateKind {
738 File(PathBuf),
739 Directory,
740 NonRegular,
741 RootEscape,
742 Unavailable,
743}
744
745#[derive(Clone, Debug, Eq, PartialEq)]
746struct Candidate {
747 lexical: PathBuf,
748 raw_portable: String,
749 portable: String,
750 kind: CandidateKind,
751}
752
753fn select_sources<S: SchemaSourceService + ?Sized>(
754 source: &S,
755 root: &Path,
756 manifest: &Path,
757 patterns: &[PortablePattern],
758 limits: SchemaDiscoveryLimits,
759) -> Result<Vec<SelectedSource>, SchemaDiagnostics> {
760 let mut candidates = Vec::new();
761 let mut inspected = 0usize;
762 let mut ancestors = vec![root.to_owned()];
763 walk_directory(
764 source,
765 root,
766 root,
767 &[],
768 0,
769 limits,
770 &mut inspected,
771 &mut ancestors,
772 &mut candidates,
773 )?;
774 candidates.sort_by(|left, right| {
775 left.portable
776 .cmp(&right.portable)
777 .then_with(|| left.raw_portable.cmp(&right.raw_portable))
778 });
779
780 let manifest_identity = metadata(source, manifest, "schema_manifest_unavailable")?.identity;
781 let mut selected = Vec::new();
782 let mut ownership: BTreeMap<String, String> = BTreeMap::new();
783
784 for pattern in patterns {
785 let mut matched = false;
786 for candidate in &candidates {
787 if !pattern_matches(&pattern.segments, &candidate.portable) {
788 continue;
789 }
790 matched = true;
791 if !candidate.portable.ends_with(".yaml") {
792 return Err(failure(
793 DiagnosticCategory::InvalidContract,
794 "schema_source_not_yaml",
795 "schema source patterns may select only lowercase .yaml files",
796 [
797 ("pattern", pattern.original.clone()),
798 ("path", candidate.portable.clone()),
799 ],
800 ));
801 }
802 let canonical = match &candidate.kind {
803 CandidateKind::File(canonical) => canonical,
804 CandidateKind::RootEscape => {
805 return Err(failure(
806 DiagnosticCategory::InvalidContract,
807 "schema_source_symlink_escape",
808 "schema source resolves outside the canonical schema root",
809 [("path", candidate.portable.clone())],
810 ));
811 }
812 CandidateKind::Directory
813 | CandidateKind::NonRegular
814 | CandidateKind::Unavailable => {
815 return Err(failure(
816 DiagnosticCategory::InvalidContract,
817 "schema_source_not_regular",
818 "schema source must resolve to a regular file",
819 [("path", candidate.portable.clone())],
820 ));
821 }
822 };
823 let owner_key = candidate.raw_portable.clone();
824 if let Some(first_pattern) = ownership.get(&owner_key) {
825 return Err(failure(
826 DiagnosticCategory::InvalidContract,
827 "overlapping_schema_source_patterns",
828 "schema source is selected by more than one pattern",
829 [
830 ("path", candidate.portable.clone()),
831 ("first_pattern", first_pattern.clone()),
832 ("second_pattern", pattern.original.clone()),
833 ],
834 ));
835 }
836 ownership.insert(owner_key, pattern.original.clone());
837 let state = PathState::capture(source, &candidate.lexical, canonical)?;
838 if state.target.identity == manifest_identity {
839 return Err(failure(
840 DiagnosticCategory::InvalidContract,
841 "schema_manifest_selected_as_source",
842 "schema source pattern may not select the schema-set manifest",
843 [("path", candidate.portable.clone())],
844 ));
845 }
846 selected.push(SelectedSource {
847 lexical: candidate.lexical.clone(),
848 raw_portable: candidate.raw_portable.clone(),
849 portable: candidate.portable.clone(),
850 canonical: canonical.clone(),
851 state,
852 });
853 }
854 if !matched {
855 return Err(failure(
856 DiagnosticCategory::InvalidContract,
857 "empty_schema_source_pattern",
858 "every schema source pattern must match at least one source",
859 [("pattern", pattern.original.clone())],
860 ));
861 }
862 }
863
864 if selected.len() > limits.parse_limits().max_documents() {
865 return Err(resource_failure(
866 "schema_document_count_limit",
867 "discovered schema document count exceeds its configured limit",
868 [
869 ("actual", selected.len().to_string()),
870 ("maximum", limits.parse_limits().max_documents().to_string()),
871 ],
872 ));
873 }
874
875 selected.sort_by(|left, right| left.portable.cmp(&right.portable));
876 reject_path_collisions(&selected)?;
877 reject_file_aliases(&selected)?;
878 Ok(selected)
879}
880
881#[allow(clippy::too_many_arguments)]
882fn walk_directory<S: SchemaSourceService + ?Sized>(
883 source: &S,
884 root: &Path,
885 physical_directory: &Path,
886 relative_segments: &[String],
887 depth: usize,
888 limits: SchemaDiscoveryLimits,
889 inspected: &mut usize,
890 ancestors: &mut Vec<PathBuf>,
891 candidates: &mut Vec<Candidate>,
892) -> Result<(), SchemaDiagnostics> {
893 if depth > limits.max_depth() {
894 return Err(resource_failure(
895 "schema_discovery_depth_limit",
896 "schema source discovery exceeds its configured directory depth",
897 [("maximum", limits.max_depth().to_string())],
898 ));
899 }
900 let mut entries = source
901 .read_directory_names(physical_directory)
902 .map_err(|_| {
903 failure(
904 DiagnosticCategory::InvalidContract,
905 "schema_directory_unavailable",
906 "schema source directory cannot be read",
907 [("path", display_path(physical_directory))],
908 )
909 })?;
910 entries.sort();
911
912 for file_name in entries {
913 *inspected = inspected.checked_add(1).ok_or_else(|| {
914 resource_failure(
915 "schema_discovery_entry_limit",
916 "schema source discovery entry count overflowed",
917 std::iter::empty::<(&str, String)>(),
918 )
919 })?;
920 if *inspected > limits.max_entries() {
921 return Err(resource_failure(
922 "schema_discovery_entry_limit",
923 "schema source discovery exceeds its configured entry limit",
924 [("maximum", limits.max_entries().to_string())],
925 ));
926 }
927
928 let file_name = file_name.into_string().map_err(|_| {
929 failure(
930 DiagnosticCategory::InvalidContract,
931 "schema_source_path_not_utf8",
932 "schema source paths must be valid UTF-8",
933 std::iter::empty::<(&str, String)>(),
934 )
935 })?;
936 let mut raw_segments = relative_segments.to_vec();
937 raw_segments.push(file_name.clone());
938 let raw_portable = raw_segments.join("/");
939 let normalized_segments = raw_segments
940 .iter()
941 .map(|segment| segment.nfc().collect::<String>())
942 .collect::<Vec<_>>();
943 let portable = normalized_segments.join("/");
944 let lexical = root.join(raw_segments.iter().collect::<PathBuf>());
945 let canonical = source.canonicalize(&lexical);
946 let kind = match canonical {
947 Ok(canonical) if !canonical.starts_with(root) => CandidateKind::RootEscape,
948 Ok(canonical) => match source.metadata(&canonical) {
949 Ok(value) if value.kind == SchemaSourceKind::File => CandidateKind::File(canonical),
950 Ok(value) if value.kind == SchemaSourceKind::Directory => CandidateKind::Directory,
951 Ok(_) => CandidateKind::NonRegular,
952 Err(_) => CandidateKind::Unavailable,
953 },
954 Err(_) => CandidateKind::Unavailable,
955 };
956 candidates.push(Candidate {
957 lexical: lexical.clone(),
958 raw_portable,
959 portable,
960 kind: kind.clone(),
961 });
962
963 if matches!(kind, CandidateKind::RootEscape) {
964 let link_metadata = source.symlink_metadata(&lexical).ok();
965 if link_metadata
966 .as_ref()
967 .is_some_and(|metadata| metadata.kind == SchemaSourceKind::Symlink)
968 {
969 return Err(failure(
970 DiagnosticCategory::InvalidContract,
971 "schema_source_symlink_escape",
972 "schema source directory resolves outside the canonical schema root",
973 [("path", normalized_segments.join("/"))],
974 ));
975 }
976 }
977 if !matches!(kind, CandidateKind::Directory) {
978 continue;
979 }
980 let canonical_directory =
981 canonicalize_path(source, &lexical, "schema_directory_unavailable")?;
982 if ancestors.contains(&canonical_directory) {
983 return Err(failure(
984 DiagnosticCategory::InvalidContract,
985 "schema_source_symlink_cycle",
986 "schema source directory symlink forms a cycle",
987 [("path", normalized_segments.join("/"))],
988 ));
989 }
990 ancestors.push(canonical_directory.clone());
991 walk_directory(
992 source,
993 root,
994 &canonical_directory,
995 &raw_segments,
996 depth + 1,
997 limits,
998 inspected,
999 ancestors,
1000 candidates,
1001 )?;
1002 ancestors.pop();
1003 }
1004 Ok(())
1005}
1006
1007fn pattern_matches(pattern: &[PatternSegment], path: &str) -> bool {
1008 let path = path.split('/').collect::<Vec<_>>();
1009 let mut table = vec![vec![false; path.len() + 1]; pattern.len() + 1];
1010 table[0][0] = true;
1011 for pattern_index in 1..=pattern.len() {
1012 match &pattern[pattern_index - 1] {
1013 PatternSegment::Recursive => {
1014 for path_index in 0..=path.len() {
1015 table[pattern_index][path_index] = table[pattern_index - 1][path_index]
1016 || (path_index > 0 && table[pattern_index][path_index - 1]);
1017 }
1018 }
1019 PatternSegment::Component(component) => {
1020 for path_index in 1..=path.len() {
1021 table[pattern_index][path_index] = table[pattern_index - 1][path_index - 1]
1022 && component_matches(component, path[path_index - 1]);
1023 }
1024 }
1025 }
1026 }
1027 table[pattern.len()][path.len()]
1028}
1029
1030fn component_matches(pattern: &str, value: &str) -> bool {
1031 let pattern = pattern.chars().collect::<Vec<_>>();
1032 let value = value.chars().collect::<Vec<_>>();
1033 let mut previous = vec![false; value.len() + 1];
1034 previous[0] = true;
1035 for token in pattern {
1036 let mut current = vec![false; value.len() + 1];
1037 if token == '*' {
1038 current[0] = previous[0];
1039 }
1040 for index in 1..=value.len() {
1041 current[index] = match token {
1042 '*' => previous[index] || current[index - 1],
1043 '?' => previous[index - 1],
1044 literal => previous[index - 1] && literal == value[index - 1],
1045 };
1046 }
1047 previous = current;
1048 }
1049 previous[value.len()]
1050}
1051
1052#[derive(Clone, Debug, Eq, PartialEq)]
1053struct SelectedSource {
1054 lexical: PathBuf,
1055 raw_portable: String,
1056 portable: String,
1057 canonical: PathBuf,
1058 state: PathState,
1059}
1060
1061fn reject_path_collisions(selected: &[SelectedSource]) -> Result<(), SchemaDiagnostics> {
1062 let mut collisions: BTreeMap<String, String> = BTreeMap::new();
1063 for source in selected {
1064 let key = source.portable.case_fold().nfc().collect::<String>();
1065 if let Some(first) = collisions.get(&key) {
1066 if first != &source.raw_portable {
1067 return Err(failure(
1068 DiagnosticCategory::InvalidContract,
1069 "schema_source_path_collision",
1070 "schema sources collide after NFC normalization or Unicode case-folding",
1071 [
1072 ("first_path", first.clone()),
1073 ("second_path", source.raw_portable.clone()),
1074 ],
1075 ));
1076 }
1077 } else {
1078 collisions.insert(key, source.raw_portable.clone());
1079 }
1080 }
1081 for source in selected {
1082 if source.raw_portable != source.portable {
1083 return Err(failure(
1084 DiagnosticCategory::InvalidContract,
1085 "schema_source_path_not_nfc",
1086 "schema source paths must use NFC spelling",
1087 [("path", source.raw_portable.clone())],
1088 ));
1089 }
1090 }
1091 Ok(())
1092}
1093
1094fn reject_file_aliases(selected: &[SelectedSource]) -> Result<(), SchemaDiagnostics> {
1095 let mut identities: BTreeMap<SchemaSourceIdentity, String> = BTreeMap::new();
1096 for source in selected {
1097 if let Some(first) = identities.get(&source.state.target.identity) {
1098 return Err(failure(
1099 DiagnosticCategory::InvalidContract,
1100 "schema_source_file_alias",
1101 "multiple schema source paths resolve to the same file identity",
1102 [
1103 ("first_path", first.clone()),
1104 ("second_path", source.portable.clone()),
1105 ],
1106 ));
1107 }
1108 identities.insert(
1109 source.state.target.identity.clone(),
1110 source.portable.clone(),
1111 );
1112 }
1113 Ok(())
1114}
1115
1116fn capture_sources<S: SchemaSourceService + ?Sized>(
1117 service: &S,
1118 selected: &[SelectedSource],
1119 limits: SchemaParseLimits,
1120) -> Result<Vec<(DocumentId, String)>, SchemaDiagnostics> {
1121 let mut captured = Vec::with_capacity(selected.len());
1122 let mut aggregate_bytes = 0usize;
1123 for source in selected {
1124 if !source
1125 .state
1126 .matches(service, &source.lexical, &source.canonical)
1127 {
1128 return Err(snapshot_changed("schema source changed before it was read"));
1129 }
1130 if source.state.target.len > limits.max_document_bytes() as u64 {
1131 return Err(resource_failure(
1132 "schema_document_size_limit",
1133 "schema source exceeds its configured byte limit",
1134 [
1135 ("path", source.portable.clone()),
1136 ("maximum_bytes", limits.max_document_bytes().to_string()),
1137 ],
1138 ));
1139 }
1140
1141 let SchemaSourceCapture {
1142 bytes,
1143 before,
1144 after,
1145 } = service
1146 .capture_file(&source.canonical, limits.max_document_bytes())
1147 .map_err(|_| snapshot_changed("schema source became unavailable while being read"))?;
1148 if before != source.state.target {
1149 return Err(snapshot_changed(
1150 "schema source identity changed before it was read",
1151 ));
1152 }
1153 if bytes.len() > limits.max_document_bytes() {
1154 return Err(resource_failure(
1155 "schema_document_size_limit",
1156 "schema source exceeds its configured byte limit",
1157 [
1158 ("path", source.portable.clone()),
1159 ("maximum_bytes", limits.max_document_bytes().to_string()),
1160 ],
1161 ));
1162 }
1163 if before != after
1164 || !source
1165 .state
1166 .matches(service, &source.lexical, &source.canonical)
1167 {
1168 return Err(snapshot_changed("schema source changed while it was read"));
1169 }
1170
1171 aggregate_bytes = aggregate_bytes.checked_add(bytes.len()).ok_or_else(|| {
1172 resource_failure(
1173 "schema_aggregate_size_limit",
1174 "schema aggregate source size overflowed",
1175 std::iter::empty::<(&str, String)>(),
1176 )
1177 })?;
1178 if aggregate_bytes > limits.max_aggregate_bytes() {
1179 return Err(resource_failure(
1180 "schema_aggregate_size_limit",
1181 "schema aggregate source size exceeds its configured limit",
1182 [("maximum_bytes", limits.max_aggregate_bytes().to_string())],
1183 ));
1184 }
1185 let source_text = String::from_utf8(bytes).map_err(|_| {
1186 failure(
1187 DiagnosticCategory::InvalidContract,
1188 "schema_source_not_utf8",
1189 "schema source content must be valid UTF-8",
1190 [("path", source.portable.clone())],
1191 )
1192 })?;
1193 let document = DocumentId::new(source.portable.clone()).map_err(|diagnostic| {
1194 SchemaDiagnostics::one(SchemaDiagnostic::new(diagnostic, None))
1195 })?;
1196 captured.push((document, source_text));
1197 }
1198 Ok(captured)
1199}
1200
1201fn revalidate_captured_sources<S: SchemaSourceService + ?Sized>(
1202 source: &S,
1203 selected: &[SelectedSource],
1204 captured: &[(DocumentId, String)],
1205 limits: SchemaParseLimits,
1206) -> Result<(), SchemaDiagnostics> {
1207 let current = capture_sources(source, selected, limits)
1208 .map_err(|_| snapshot_changed("schema source content changed before parsing"))?;
1209 if current == captured {
1210 Ok(())
1211 } else {
1212 Err(snapshot_changed(
1213 "schema source content changed before parsing",
1214 ))
1215 }
1216}
1217
1218#[cfg(test)]
1219mod content_integrity_tests {
1220 use std::sync::atomic::{AtomicU64, Ordering};
1221
1222 use super::*;
1223
1224 static NEXT_TEMP_DIRECTORY: AtomicU64 = AtomicU64::new(0);
1225
1226 struct TempDirectory(PathBuf);
1227
1228 impl TempDirectory {
1229 fn new() -> Self {
1230 let sequence = NEXT_TEMP_DIRECTORY.fetch_add(1, Ordering::Relaxed);
1231 let path = std::env::temp_dir().join(format!(
1232 "type-bridge-schema-content-integrity-{}-{sequence}",
1233 std::process::id()
1234 ));
1235 fs::create_dir_all(path.join("fragments")).expect("create test schema directory");
1236 fs::write(
1237 path.join("schema.yaml"),
1238 "format: typebridge.schema-set/v1\n",
1239 )
1240 .expect("write test manifest");
1241 fs::write(path.join("fragments/a.yaml"), "root: a\n")
1242 .expect("write initial schema source");
1243 Self(path)
1244 }
1245 }
1246
1247 impl Drop for TempDirectory {
1248 fn drop(&mut self) {
1249 let _ = fs::remove_dir_all(&self.0);
1250 }
1251 }
1252
1253 #[test]
1254 fn discovery_revalidation_detects_same_length_content_replacement() {
1255 let directory = TempDirectory::new();
1256 let root = fs::canonicalize(&directory.0).expect("canonicalize test root");
1257 let manifest =
1258 fs::canonicalize(directory.0.join("schema.yaml")).expect("canonicalize test manifest");
1259 let limits = SchemaDiscoveryLimits::default();
1260 let service = SystemSchemaSourceService;
1261 let patterns =
1262 validate_patterns(["fragments/a.yaml"], limits).expect("validate test source pattern");
1263 let mut selected = select_sources(&service, &root, &manifest, &patterns, limits)
1264 .expect("select initial schema source");
1265 let captured = capture_sources(&service, &selected, limits.parse_limits())
1266 .expect("capture initial schema source");
1267
1268 fs::write(directory.0.join("fragments/a.yaml"), "root: b\n")
1269 .expect("replace schema source with same-length content");
1270 assert_eq!(captured[0].1.len(), "root: b\n".len());
1271
1272 selected[0].state =
1273 PathState::capture(&service, &selected[0].lexical, &selected[0].canonical)
1274 .expect("neutralize metadata detection to exercise the content guard");
1275 let error =
1276 revalidate_captured_sources(&service, &selected, &captured, limits.parse_limits())
1277 .expect_err("same-length content replacement must fail discovery");
1278
1279 assert_eq!(
1280 error
1281 .iter()
1282 .next()
1283 .expect("one integrity diagnostic")
1284 .diagnostic()
1285 .code()
1286 .as_str(),
1287 "schema_discovery_snapshot_changed",
1288 );
1289 }
1290}
1291
1292fn capture_manifest_source<S: SchemaSourceService + ?Sized>(
1293 source: &S,
1294 lexical: &Path,
1295 canonical: &Path,
1296 state: &PathState,
1297 limits: SchemaParseLimits,
1298) -> Result<String, SchemaDiagnostics> {
1299 if !state.matches(source, lexical, canonical) {
1300 return Err(snapshot_changed(
1301 "schema-set manifest changed before it was read",
1302 ));
1303 }
1304 if state.target.len > limits.max_document_bytes() as u64 {
1305 return Err(resource_failure(
1306 "schema_manifest_size_limit",
1307 "schema-set manifest exceeds its configured byte limit",
1308 [("maximum_bytes", limits.max_document_bytes().to_string())],
1309 ));
1310 }
1311 let SchemaSourceCapture {
1312 bytes,
1313 before,
1314 after,
1315 } = source
1316 .capture_file(canonical, limits.max_document_bytes())
1317 .map_err(|_| snapshot_changed("schema-set manifest became unavailable while being read"))?;
1318 if before != state.target {
1319 return Err(snapshot_changed(
1320 "schema-set manifest identity changed before it was read",
1321 ));
1322 }
1323 if bytes.len() > limits.max_document_bytes() {
1324 return Err(resource_failure(
1325 "schema_manifest_size_limit",
1326 "schema-set manifest exceeds its configured byte limit",
1327 [("maximum_bytes", limits.max_document_bytes().to_string())],
1328 ));
1329 }
1330 if before != after || !state.matches(source, lexical, canonical) {
1331 return Err(snapshot_changed(
1332 "schema-set manifest changed while it was read",
1333 ));
1334 }
1335 String::from_utf8(bytes).map_err(|_| {
1336 failure(
1337 DiagnosticCategory::InvalidContract,
1338 "schema_manifest_not_utf8",
1339 "schema-set manifest content must be valid UTF-8",
1340 std::iter::empty::<(&str, String)>(),
1341 )
1342 })
1343}
1344
1345#[derive(Clone, Debug, Eq, PartialEq)]
1346struct PathState {
1347 lexical: SchemaSourceObservation,
1348 target: SchemaSourceObservation,
1349}
1350
1351impl PathState {
1352 fn capture<S: SchemaSourceService + ?Sized>(
1353 source: &S,
1354 lexical: &Path,
1355 canonical: &Path,
1356 ) -> Result<Self, SchemaDiagnostics> {
1357 let lexical_metadata = source
1358 .symlink_metadata(lexical)
1359 .map_err(|_| snapshot_changed("schema source path metadata became unavailable"))?;
1360 let target_metadata = metadata(source, canonical, "schema_source_unavailable")?;
1361 Ok(Self {
1362 lexical: lexical_metadata,
1363 target: target_metadata,
1364 })
1365 }
1366
1367 fn matches<S: SchemaSourceService + ?Sized>(
1368 &self,
1369 source: &S,
1370 lexical: &Path,
1371 canonical: &Path,
1372 ) -> bool {
1373 let Ok(current_canonical) = source.canonicalize(lexical) else {
1374 return false;
1375 };
1376 if current_canonical != canonical {
1377 return false;
1378 }
1379 let Ok(lexical_stamp) = source.symlink_metadata(lexical) else {
1380 return false;
1381 };
1382 let Ok(target_stamp) = source.metadata(canonical) else {
1383 return false;
1384 };
1385 self.lexical == lexical_stamp && self.target == target_stamp
1386 }
1387}
1388
1389fn observation(
1390 metadata: &Metadata,
1391 path: &Path,
1392) -> Result<SchemaSourceObservation, SchemaSourceServiceError> {
1393 let modified = metadata.modified().map_err(|_| SchemaSourceServiceError)?;
1394 let revision = match modified.duration_since(UNIX_EPOCH) {
1395 Ok(duration) => format!("after:{}:{}", duration.as_secs(), duration.subsec_nanos()),
1396 Err(error) => {
1397 let duration = error.duration();
1398 format!("before:{}:{}", duration.as_secs(), duration.subsec_nanos())
1399 }
1400 };
1401 let kind = if metadata.is_file() {
1402 SchemaSourceKind::File
1403 } else if metadata.is_dir() {
1404 SchemaSourceKind::Directory
1405 } else if metadata.is_symlink() {
1406 SchemaSourceKind::Symlink
1407 } else {
1408 SchemaSourceKind::Other
1409 };
1410 Ok(SchemaSourceObservation::new(
1411 system_identity(metadata, path)?,
1412 SchemaSourceRevision::new(revision)?,
1413 metadata.len(),
1414 kind,
1415 ))
1416}
1417
1418#[cfg(unix)]
1419fn system_identity(
1420 metadata: &Metadata,
1421 _path: &Path,
1422) -> Result<SchemaSourceIdentity, SchemaSourceServiceError> {
1423 use std::os::unix::fs::MetadataExt;
1424 SchemaSourceIdentity::new(format!("unix:{}:{}", metadata.dev(), metadata.ino()))
1425}
1426
1427#[cfg(not(unix))]
1428fn system_identity(
1429 _metadata: &Metadata,
1430 path: &Path,
1431) -> Result<SchemaSourceIdentity, SchemaSourceServiceError> {
1432 SchemaSourceIdentity::new(path.to_string_lossy())
1433}
1434
1435fn reject_snapshot_change<T: PartialEq>(
1436 before: &[T],
1437 after: &[T],
1438) -> Result<(), SchemaDiagnostics> {
1439 if before == after {
1440 Ok(())
1441 } else {
1442 Err(snapshot_changed(
1443 "schema source selection changed during discovery",
1444 ))
1445 }
1446}
1447
1448fn canonicalize_path<S: SchemaSourceService + ?Sized>(
1449 source: &S,
1450 path: &Path,
1451 code: &'static str,
1452) -> Result<PathBuf, SchemaDiagnostics> {
1453 source.canonicalize(path).map_err(|_| {
1454 failure(
1455 DiagnosticCategory::InvalidContract,
1456 code,
1457 "schema discovery path cannot be resolved",
1458 [("path", display_path(path))],
1459 )
1460 })
1461}
1462
1463fn metadata<S: SchemaSourceService + ?Sized>(
1464 source: &S,
1465 path: &Path,
1466 code: &'static str,
1467) -> Result<SchemaSourceObservation, SchemaDiagnostics> {
1468 source.metadata(path).map_err(|_| {
1469 failure(
1470 DiagnosticCategory::InvalidContract,
1471 code,
1472 "schema discovery path metadata cannot be read",
1473 [("path", display_path(path))],
1474 )
1475 })
1476}
1477
1478fn display_path(path: &Path) -> String {
1479 path.to_string_lossy().into_owned()
1480}
1481
1482fn snapshot_changed(message: &'static str) -> SchemaDiagnostics {
1483 failure(
1484 DiagnosticCategory::Integrity,
1485 "schema_discovery_snapshot_changed",
1486 message,
1487 std::iter::empty::<(&str, String)>(),
1488 )
1489}
1490
1491fn resource_failure<I, K>(
1492 code: &'static str,
1493 message: &'static str,
1494 details: I,
1495) -> SchemaDiagnostics
1496where
1497 I: IntoIterator<Item = (K, String)>,
1498 K: Into<String>,
1499{
1500 failure(DiagnosticCategory::ResourceLimit, code, message, details)
1501}
1502
1503fn failure<I, K>(
1504 category: DiagnosticCategory,
1505 code: &'static str,
1506 message: &'static str,
1507 details: I,
1508) -> SchemaDiagnostics
1509where
1510 I: IntoIterator<Item = (K, String)>,
1511 K: Into<String>,
1512{
1513 let mut diagnostic = Diagnostic::new(
1514 category,
1515 DiagnosticCode::new(code).expect("static schema diagnostic code is valid"),
1516 message,
1517 );
1518 for (key, value) in details {
1519 diagnostic = diagnostic.with_detail(key, value);
1520 }
1521 SchemaDiagnostics::one(SchemaDiagnostic::new(diagnostic, None))
1522}
1523
1524#[cfg(test)]
1525mod tests {
1526 use super::reject_snapshot_change;
1527
1528 #[test]
1529 fn changed_selection_uses_the_integrity_diagnostic() {
1530 let error = reject_snapshot_change(&["before"], &["after"])
1531 .expect_err("changed selections must fail closed");
1532 let item = error.iter().next().expect("one diagnostic");
1533 assert_eq!(item.diagnostic().category().as_str(), "integrity");
1534 assert_eq!(
1535 item.diagnostic().code().as_str(),
1536 "schema_discovery_snapshot_changed"
1537 );
1538 assert!(item.primary().is_none());
1539 }
1540}