Skip to main content

type_bridge_schema/
discovery.rs

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
24/// Default maximum number of source patterns in one schema-set manifest.
25pub const DEFAULT_MAX_SOURCE_PATTERNS: usize = 4_096;
26/// Default maximum UTF-8 bytes in one portable source pattern.
27pub const DEFAULT_MAX_SOURCE_PATTERN_BYTES: usize = MAX_DOCUMENT_ID_BYTES;
28/// Default maximum filesystem entries inspected during one selection.
29pub const DEFAULT_MAX_DISCOVERY_ENTRIES: usize = 65_536;
30/// Default maximum directory depth traversed below the schema root.
31pub const DEFAULT_MAX_DISCOVERY_DEPTH: usize = 64;
32
33/// An unavailable or inconsistent observation from a schema source service.
34#[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/// The non-following or following kind observed for one source path.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum SchemaSourceKind {
48    /// A regular file.
49    File,
50    /// A directory.
51    Directory,
52    /// A symbolic link observed without following it.
53    Symlink,
54    /// Any other filesystem-like object.
55    Other,
56}
57
58/// One service-defined stable object identity.
59#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
60pub struct SchemaSourceIdentity(String);
61
62impl SchemaSourceIdentity {
63    /// Creates an opaque identity token whose equality is meaningful to the service.
64    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/// One service-defined content or metadata revision token.
74#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct SchemaSourceRevision(String);
76
77impl SchemaSourceRevision {
78    /// Creates an opaque revision token whose equality is meaningful to the service.
79    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/// One point-in-time source-path observation.
89#[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    /// Creates one observation supplied by an injected source service.
99    #[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    /// Returns the service-defined stable identity.
115    #[must_use]
116    pub const fn identity(&self) -> &SchemaSourceIdentity {
117        &self.identity
118    }
119
120    /// Returns the service-defined revision token.
121    #[must_use]
122    pub const fn revision(&self) -> &SchemaSourceRevision {
123        &self.revision
124    }
125
126    /// Returns the observed byte length.
127    #[must_use]
128    pub const fn len(&self) -> u64 {
129        self.len
130    }
131
132    /// Reports whether the observed object has zero bytes.
133    #[must_use]
134    pub const fn is_empty(&self) -> bool {
135        self.len == 0
136    }
137
138    /// Returns the observed object kind.
139    #[must_use]
140    pub const fn kind(&self) -> SchemaSourceKind {
141        self.kind
142    }
143}
144
145/// One bounded file capture with observations from before and after the read.
146#[derive(Clone, Debug, Eq, PartialEq)]
147pub struct SchemaSourceCapture {
148    bytes: Vec<u8>,
149    before: SchemaSourceObservation,
150    after: SchemaSourceObservation,
151}
152
153impl SchemaSourceCapture {
154    /// Creates a bounded capture. The discovery algorithm rechecks every claim.
155    #[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    /// Returns the exact captured bytes.
169    #[must_use]
170    pub fn bytes(&self) -> &[u8] {
171        &self.bytes
172    }
173
174    /// Returns the observation immediately before capture.
175    #[must_use]
176    pub const fn before(&self) -> &SchemaSourceObservation {
177        &self.before
178    }
179
180    /// Returns the observation immediately after capture.
181    #[must_use]
182    pub const fn after(&self) -> &SchemaSourceObservation {
183        &self.after
184    }
185}
186
187/// Bounded environmental observations used by deterministic schema discovery.
188///
189/// Implementations supply raw observations only. The shared loader retains
190/// confinement, matching, alias/collision rejection, reselection, parsing, and
191/// evidence construction.
192pub trait SchemaSourceService {
193    /// Resolves one path to its canonical physical path.
194    fn canonicalize(&self, path: &Path) -> Result<PathBuf, SchemaSourceServiceError>;
195
196    /// Observes one path while following symbolic links.
197    fn metadata(&self, path: &Path) -> Result<SchemaSourceObservation, SchemaSourceServiceError>;
198
199    /// Observes one path without following its final symbolic link.
200    fn symlink_metadata(
201        &self,
202        path: &Path,
203    ) -> Result<SchemaSourceObservation, SchemaSourceServiceError>;
204
205    /// Returns direct entry names in ascending platform byte order.
206    fn read_directory_names(&self, path: &Path) -> Result<Vec<OsString>, SchemaSourceServiceError>;
207
208    /// Captures at most `maximum_bytes + 1` bytes with before/after observations.
209    fn capture_file(
210        &self,
211        path: &Path,
212        maximum_bytes: usize,
213    ) -> Result<SchemaSourceCapture, SchemaSourceServiceError>;
214}
215
216/// Zero-sized adapter for the host filesystem.
217#[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/// Resource ceilings for deterministic schema source discovery and parsing.
282#[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    /// Creates explicit discovery and parser ceilings.
293    #[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    /// Returns the limits applied to captured document bytes and YAML parsing.
311    #[must_use]
312    pub const fn parse_limits(self) -> SchemaParseLimits {
313        self.parse_limits
314    }
315
316    /// Returns the source-pattern count ceiling.
317    #[must_use]
318    pub const fn max_patterns(self) -> usize {
319        self.max_patterns
320    }
321
322    /// Returns the per-pattern UTF-8 byte ceiling.
323    #[must_use]
324    pub const fn max_pattern_bytes(self) -> usize {
325        self.max_pattern_bytes
326    }
327
328    /// Returns the filesystem-entry inspection ceiling.
329    #[must_use]
330    pub const fn max_entries(self) -> usize {
331        self.max_entries
332    }
333
334    /// Returns the directory traversal-depth ceiling.
335    #[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/// An immutable set of captured schema source bytes parsed after revalidation.
354#[derive(Clone, Debug, Eq, PartialEq)]
355pub struct SchemaPatternDiscoverySnapshot {
356    root: PathBuf,
357    manifest: PathBuf,
358    documents: SchemaDocumentSet,
359}
360
361impl SchemaPatternDiscoverySnapshot {
362    /// Returns the canonical schema root used for confinement.
363    #[must_use]
364    pub fn root(&self) -> &Path {
365        &self.root
366    }
367
368    /// Returns the canonical schema-set manifest path.
369    #[must_use]
370    pub fn manifest(&self) -> &Path {
371        &self.manifest
372    }
373
374    /// Returns the captured, fingerprinted document set.
375    #[must_use]
376    pub const fn documents(&self) -> &SchemaDocumentSet {
377        &self.documents
378    }
379
380    /// Consumes the snapshot and returns its captured document set.
381    #[must_use]
382    pub fn into_documents(self) -> SchemaDocumentSet {
383        self.documents
384    }
385}
386
387/// One portable path and exact-source digest captured by schema discovery.
388#[derive(Clone, Debug, Eq, PartialEq)]
389pub struct SchemaSourceEvidence {
390    path: DocumentId,
391    fingerprint: DocumentFingerprint,
392}
393
394impl SchemaSourceEvidence {
395    /// Returns the canonical schema-root-relative portable path.
396    #[must_use]
397    pub const fn path(&self) -> &DocumentId {
398        &self.path
399    }
400
401    /// Returns the exact-source document fingerprint.
402    #[must_use]
403    pub const fn fingerprint(&self) -> &DocumentFingerprint {
404        &self.fingerprint
405    }
406}
407
408/// Reproducible Phase 2 input for the later workspace lock producer.
409#[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    /// Returns the frozen discovery algorithm version.
419    #[must_use]
420    pub const fn discovery_version(&self) -> &SchemaDiscoveryVersion {
421        &self.discovery_version
422    }
423
424    /// Returns the exact manifest-source fingerprint.
425    #[must_use]
426    pub const fn manifest_fingerprint(&self) -> &DocumentFingerprint {
427        &self.manifest_fingerprint
428    }
429
430    /// Returns source paths and fingerprints in canonical path order.
431    #[must_use]
432    pub fn sources(&self) -> &[SchemaSourceEvidence] {
433        &self.sources
434    }
435
436    /// Returns the aggregate document-set fingerprint.
437    #[must_use]
438    pub const fn document_set_fingerprint(&self) -> &SchemaDocumentSetFingerprint {
439        &self.document_set_fingerprint
440    }
441}
442
443/// One atomically captured schema-set manifest and its selected fragments.
444#[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    /// Returns the canonical schema root used for confinement.
455    #[must_use]
456    pub fn root(&self) -> &Path {
457        &self.root
458    }
459
460    /// Returns the exact parsed manifest captured with the selected fragments.
461    #[must_use]
462    pub const fn manifest(&self) -> &SchemaSetManifestDocument {
463        &self.manifest
464    }
465
466    /// Returns the captured, fingerprinted document set.
467    #[must_use]
468    pub const fn documents(&self) -> &SchemaDocumentSet {
469        &self.documents
470    }
471
472    /// Returns the frozen source-discovery algorithm version.
473    #[must_use]
474    pub const fn discovery_version(&self) -> &SchemaDiscoveryVersion {
475        &self.discovery_version
476    }
477
478    /// Returns lock-producer evidence containing no absolute host paths.
479    #[must_use]
480    pub const fn evidence(&self) -> &SchemaDiscoveryEvidence {
481        &self.evidence
482    }
483
484    /// Consumes the snapshot and returns its manifest-associated document set.
485    #[must_use]
486    pub fn into_documents(self) -> SchemaDocumentSet {
487        self.documents
488    }
489}
490
491/// Discovers and freezes schema documents with default resource ceilings.
492pub 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
503/// Discovers and freezes schema documents with explicit resource ceilings.
504pub 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
594/// Loads a strict schema-set manifest and atomically freezes all selected documents.
595pub fn load_schema_set(
596    manifest: impl AsRef<Path>,
597) -> Result<SchemaDiscoverySnapshot, SchemaDiagnostics> {
598    load_schema_set_with_limits(manifest, SchemaDiscoveryLimits::default())
599}
600
601/// Loads a strict schema-set manifest with explicit discovery and parser ceilings.
602pub 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
609/// Loads a schema set through an injected bounded source-observation service.
610pub 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}