Skip to main content

keyhog_core/spec/
load.rs

1//! Detector loading pipeline: read TOML files and run the quality gate.
2
3#![allow(clippy::result_large_err)] // SpecError carries a 128-byte toml::de::Error; boxing it would be a breaking API change.
4
5use std::path::{Path, PathBuf};
6
7use rayon::prelude::*;
8use thiserror::Error;
9
10use super::{
11    migrate_legacy_success_policies, validate_detector, DetectorCorpusManifest, DetectorFile,
12    DetectorSpec, QualityIssue, DETECTOR_CORPUS_MANIFEST_FILE,
13    DETECTOR_CORPUS_MAX_FORWARD_SCHEMA_VERSION, DETECTOR_CORPUS_MIN_SCHEMA_VERSION,
14    DETECTOR_CORPUS_SCHEMA_VERSION,
15};
16pub use crate::detector_file_io::{read_detector_toml_file, DETECTOR_TOML_FILE_BYTES};
17
18/// Errors returned while loading or validating detector specifications.
19#[derive(Debug, Error)]
20#[allow(clippy::result_large_err)] // SpecError variants include 128-byte toml::de::Error; boxing would be a breaking API change.
21pub enum SpecError {
22    #[error(
23        "failed to read detector path {path}: {source}. Fix: check the detector path exists and that the file is readable TOML"
24    )]
25    /// A detector path could not be read.
26    ReadFile {
27        /// Detector path that failed to read.
28        path: String,
29        /// Underlying I/O error.
30        source: std::io::Error,
31    },
32    #[error(
33        "invalid TOML in detector {path}: {source}. Fix: repair the TOML syntax in the detector file"
34    )]
35    /// A detector file is not valid TOML.
36    InvalidToml {
37        /// Detector file that failed to parse.
38        path: PathBuf,
39        /// Underlying TOML error.
40        source: toml::de::Error,
41    },
42    #[error(
43        "invalid detector corpus manifest {path}: {source}. Fix: set `schema_version` \
44         to an integer supported by this keyhog binary and remove misspelled manifest fields"
45    )]
46    /// A directory `corpus.toml` manifest is not valid TOML.
47    InvalidCorpusManifest {
48        /// Manifest file that failed to parse.
49        path: PathBuf,
50        /// Underlying TOML error.
51        source: toml::de::Error,
52    },
53    #[error(
54        "unsupported detector corpus schema {found} declared by {path}; this binary \
55         supports schema {current} and bounded forward compatibility through schema \
56         {max_forward}. Fix: use a compatible detector corpus or update keyhog"
57    )]
58    /// A corpus declares a schema outside this binary's compatibility window.
59    UnsupportedCorpusSchema {
60        /// Manifest that declared the schema.
61        path: PathBuf,
62        /// Schema version the corpus declared.
63        found: u32,
64        /// Schema version this binary owns.
65        current: u32,
66        /// Highest schema this binary may inspect additively.
67        max_forward: u32,
68    },
69    #[error(
70        "detector corpus {dir} declares supported forward schema {declared_schema}, \
71         while this binary owns schema {supported_schema}; {skipped_count} of {total} \
72         detector file(s) use fields this binary cannot interpret. Keyhog refuses to \
73         scan under newer parsing semantics or with a partial corpus because either \
74         would invalidate corpus identity and could silently drop recall. \
75         Compatibility detail:\n{detail}\nFix: update keyhog to load the complete \
76         detector corpus"
77    )]
78    /// A newer corpus uses fields this binary cannot interpret, so loading it would silently drop recall.
79    ForwardIncompatibleCorpus {
80        /// Detector directory that was rejected.
81        dir: String,
82        /// Forward schema the corpus declared.
83        declared_schema: u32,
84        /// Schema version this binary owns.
85        supported_schema: u32,
86        /// Number of detector files this binary could not interpret.
87        skipped_count: usize,
88        /// Total detector files in the directory.
89        total: usize,
90        /// Per-file compatibility detail.
91        detail: String,
92    },
93    #[error(
94        "{failed_count} of {total} embedded detector(s) failed to parse, the binary \
95         baked in a CORRUPT detector set, so its recall is silently degraded. This is \
96         a build/source bug, not a runtime condition: the embedded corpus is compiled \
97         in and cannot have been edited at runtime. Offending detector(s):\n{detail}\n\
98         Fix: repair the named TOML(s) under `detectors/` (the toml error names the \
99         line/column) and rebuild keyhog so build.rs re-embeds a valid set."
100    )]
101    /// The compiled-in detector corpus failed to parse, which is a build bug.
102    EmbeddedCorpusCorrupt {
103        /// Number of embedded detectors that failed to parse.
104        failed_count: usize,
105        /// Total embedded detectors.
106        total: usize,
107        /// Per-detector parse detail.
108        detail: String,
109    },
110    #[error(
111        "{failed_count} of {total} detector file(s) from {dir} failed to load, \
112         pass the quality gate, or exist at all, that is a partial detector \
113         corpus, so keyhog is refusing to scan without a complete detector \
114         corpus (a partial corpus silently drops recall). \
115         Offending detector(s):\n{detail}\nFix: repair the named TOML file(s) \
116         or add at least one valid `*.toml` detector spec, then rerun the scan."
117    )]
118    /// A detector directory produced a partial corpus, which would silently drop recall.
119    DetectorCorpusRejected {
120        /// Detector directory that was rejected.
121        dir: String,
122        /// Number of detector files that failed to load or gate.
123        failed_count: usize,
124        /// Total detector files considered.
125        total: usize,
126        /// Per-file failure detail.
127        detail: String,
128    },
129}
130
131/// A validated detector corpus paired with the schema identity that selected
132/// its normalization rules.
133///
134/// `schema_version` is the normalized directory identity: a missing manifest
135/// is schema 1, while an explicit manifest contributes its declared version.
136/// Keeping it beside `specs` prevents a caller from hashing normalized legacy
137/// specs as though they had been authored under the current schema.
138#[derive(Debug)]
139pub struct LoadedDetectorCorpus {
140    /// Fully parsed and validated detector specifications.
141    pub specs: Vec<DetectorSpec>,
142    /// Effective detector corpus schema version.
143    pub schema_version: u32,
144}
145
146impl LoadedDetectorCorpus {
147    /// Compute the schema-bound identity of these exact normalized specs.
148    pub fn compute_digest(&self) -> Result<[u8; 32], serde_json::Error> {
149        crate::compute_detector_corpus_digest_for_schema(&self.specs, self.schema_version)
150    }
151}
152
153/// Load all detector specs from a directory of TOML files.
154/// Runs the quality gate on each detector and fails closed if any detector
155/// cannot be read, parsed, or accepted by the gate.
156///
157/// # Examples
158///
159/// ```rust,no_run
160/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
161/// use keyhog_core::load_detectors;
162/// use std::path::Path;
163///
164/// let detectors = load_detectors(Path::new("detectors"))?;
165/// assert!(!detectors.is_empty());
166/// # Ok(()) }
167/// ```
168pub fn load_detectors(dir: &Path) -> Result<Vec<DetectorSpec>, SpecError> {
169    Ok(load_detector_corpus(dir)?.specs)
170}
171
172/// Load all detector specs together with their normalized corpus schema
173/// identity.
174pub fn load_detector_corpus(dir: &Path) -> Result<LoadedDetectorCorpus, SpecError> {
175    load_detector_corpus_with_gate(dir, true)
176}
177
178/// Load detectors with optional quality gate enforcement.
179/// When `enforce_gate` is `true`, detector read/parse/quality errors reject
180/// the entire corpus instead of returning a partial detector set.
181///
182/// # Examples
183///
184/// ```ignore
185/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
186/// // Crate-internal hook for tests and CLI detector-cache owner code.
187/// use keyhog_core::spec::load::load_detectors_with_gate;
188/// use std::path::Path;
189///
190/// let _detectors = load_detectors_with_gate(Path::new("detectors"), true)?;
191/// # Ok(()) }
192/// ```
193#[derive(Clone, Copy)]
194struct CorpusCompatibility {
195    schema_version: u32,
196    permits_forward_unknown_fields: bool,
197}
198
199pub(crate) fn load_detectors_with_gate(
200    dir: &Path,
201    enforce_gate: bool,
202) -> Result<Vec<DetectorSpec>, SpecError> {
203    Ok(load_detector_corpus_with_gate(dir, enforce_gate)?.specs)
204}
205
206fn load_detector_corpus_with_gate(
207    dir: &Path,
208    enforce_gate: bool,
209) -> Result<LoadedDetectorCorpus, SpecError> {
210    let compatibility = read_corpus_compatibility(dir)?;
211    let toml_paths = discover_detector_tomls(dir, enforce_gate)?;
212    let parsed = parse_detector_files(&toml_paths, compatibility);
213    let specs = assemble_detector_load(dir, enforce_gate, compatibility, toml_paths.len(), parsed)?;
214    Ok(LoadedDetectorCorpus {
215        specs,
216        schema_version: compatibility.schema_version,
217    })
218}
219
220fn read_corpus_compatibility(dir: &Path) -> Result<CorpusCompatibility, SpecError> {
221    let path = dir.join(DETECTOR_CORPUS_MANIFEST_FILE);
222    let contents = match std::fs::read_to_string(&path) {
223        Ok(contents) => contents,
224        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
225            return Ok(CorpusCompatibility {
226                schema_version: DETECTOR_CORPUS_MIN_SCHEMA_VERSION,
227                permits_forward_unknown_fields: false,
228            });
229        }
230        Err(source) => {
231            return Err(SpecError::ReadFile {
232                path: path.display().to_string(),
233                source,
234            });
235        }
236    };
237    let manifest: DetectorCorpusManifest =
238        toml::from_str(&contents).map_err(|source| SpecError::InvalidCorpusManifest {
239            path: path.clone(),
240            source,
241        })?;
242    if !(DETECTOR_CORPUS_MIN_SCHEMA_VERSION..=DETECTOR_CORPUS_MAX_FORWARD_SCHEMA_VERSION)
243        .contains(&manifest.schema_version)
244    {
245        return Err(SpecError::UnsupportedCorpusSchema {
246            path,
247            found: manifest.schema_version,
248            current: DETECTOR_CORPUS_SCHEMA_VERSION,
249            max_forward: DETECTOR_CORPUS_MAX_FORWARD_SCHEMA_VERSION,
250        });
251    }
252    Ok(CorpusCompatibility {
253        schema_version: manifest.schema_version,
254        permits_forward_unknown_fields: manifest.schema_version > DETECTOR_CORPUS_SCHEMA_VERSION,
255    })
256}
257
258fn discover_detector_tomls(dir: &Path, enforce_gate: bool) -> Result<Vec<PathBuf>, SpecError> {
259    let entries = std::fs::read_dir(dir).map_err(|e| SpecError::ReadFile {
260        path: dir.display().to_string(),
261        source: e,
262    })?;
263    let mut toml_paths = Vec::new();
264    for entry in entries {
265        let entry = entry.map_err(|e| SpecError::ReadFile {
266            path: format!("directory entry under {}", dir.display()),
267            source: e,
268        })?;
269        let path = entry.path();
270        if path.extension().is_some_and(|ext| ext == "toml")
271            && path
272                .file_name()
273                .is_none_or(|name| name != DETECTOR_CORPUS_MANIFEST_FILE)
274        {
275            toml_paths.push(path);
276        }
277    }
278
279    if enforce_gate && toml_paths.is_empty() {
280        return Err(SpecError::DetectorCorpusRejected {
281            dir: dir.display().to_string(),
282            failed_count: 0,
283            total: 0,
284            detail:
285                "  - no detector TOML files found; add at least one valid `*.toml` detector spec"
286                    .to_string(),
287        });
288    }
289    Ok(toml_paths)
290}
291
292fn parse_detector_files(
293    toml_paths: &[PathBuf],
294    compatibility: CorpusCompatibility,
295) -> Vec<ReadDetectorOutcome> {
296    toml_paths
297        .par_iter()
298        .map(|path| read_detector_file(path, compatibility))
299        .collect()
300}
301
302fn assemble_detector_load(
303    dir: &Path,
304    enforce_gate: bool,
305    compatibility: CorpusCompatibility,
306    total: usize,
307    parsed: Vec<ReadDetectorOutcome>,
308) -> Result<Vec<DetectorSpec>, SpecError> {
309    let mut load_state = DetectorLoadState::default();
310    let mut detectors = Vec::with_capacity(parsed.len());
311
312    for outcome in parsed {
313        match outcome {
314            ReadDetectorOutcome::Loaded {
315                path,
316                spec,
317                legacy_migrations,
318            } => {
319                load_state.legacy_migrations += legacy_migrations;
320                if should_reject_detector(
321                    &spec,
322                    &path,
323                    enforce_gate,
324                    &mut load_state.gate_rejected,
325                    &mut load_state.gate_errors,
326                    &mut load_state.total_warnings,
327                ) {
328                    continue;
329                }
330                detectors.push(*spec);
331            }
332            ReadDetectorOutcome::ForwardSkipped { message } => {
333                load_state.forward_skipped += 1;
334                load_state.forward_errors.push(message);
335            }
336            ReadDetectorOutcome::Skipped { message } => {
337                load_state.skipped += 1;
338                load_state.load_errors.push(message);
339            }
340        }
341    }
342
343    // Sort before the duplicate-id scan so identical ids are adjacent and one
344    // linear pass finds them. A detector id is a unique key, it selects the
345    // checksum validator, suppression rules, and finding attribution, so two
346    // detectors sharing an id silently shadow each other (the loser's
347    // patterns/companions never fire). Law 10: surface it, don't let it pass.
348    // Folded into the SAME gate as other corpus-integrity failures (fail closed
349    // under the gate, logged otherwise) rather than a bespoke rejection path.
350    detectors.sort_by(|a, b| a.id.cmp(&b.id));
351    let mut duplicate_ids: Vec<&str> = detectors
352        .windows(2)
353        .filter(|w| w[0].id == w[1].id)
354        .map(|w| w[0].id.as_str())
355        .collect();
356    duplicate_ids.dedup();
357    if !duplicate_ids.is_empty() {
358        load_state.gate_rejected += duplicate_ids.len();
359        for id in duplicate_ids {
360            load_state.gate_errors.push(format!(
361                "duplicate detector id `{id}` (a later spec would shadow the earlier)"
362            ));
363        }
364    }
365
366    log_load_summary(&load_state);
367    if enforce_gate && compatibility.permits_forward_unknown_fields {
368        return Err(load_state.into_forward_error(dir, total, compatibility.schema_version));
369    }
370    if enforce_gate && load_state.has_failures() {
371        return Err(load_state.into_rejected_error(dir, total));
372    }
373    Ok(detectors)
374}
375
376#[derive(Default)]
377struct DetectorLoadState {
378    skipped: usize,
379    load_errors: Vec<String>,
380    forward_skipped: usize,
381    forward_errors: Vec<String>,
382    legacy_migrations: usize,
383    gate_rejected: usize,
384    gate_errors: Vec<String>,
385    total_warnings: usize,
386}
387
388impl DetectorLoadState {
389    fn has_failures(&self) -> bool {
390        self.skipped > 0 || self.forward_skipped > 0 || self.gate_rejected > 0
391    }
392
393    fn into_rejected_error(self, dir: &Path, total: usize) -> SpecError {
394        let mut details = self.load_errors;
395        details.extend(self.gate_errors);
396        let detail = details
397            .into_iter()
398            .map(|line| format!("  - {line}"))
399            .collect::<Vec<_>>()
400            .join("\n");
401        SpecError::DetectorCorpusRejected {
402            dir: dir.display().to_string(),
403            failed_count: self.skipped + self.gate_rejected,
404            total,
405            detail,
406        }
407    }
408    fn into_forward_error(self, dir: &Path, total: usize, declared_schema: u32) -> SpecError {
409        let detail = if self.forward_errors.is_empty() {
410            format!(
411                "  - {} declares schema {}; schema metadata is part of effective \
412                 corpus identity and cannot be interpreted as schema {}",
413                dir.join(DETECTOR_CORPUS_MANIFEST_FILE).display(),
414                declared_schema,
415                DETECTOR_CORPUS_SCHEMA_VERSION
416            )
417        } else {
418            self.forward_errors
419                .into_iter()
420                .map(|line| format!("  - {line}"))
421                .collect::<Vec<_>>()
422                .join("\n")
423        };
424        SpecError::ForwardIncompatibleCorpus {
425            dir: dir.display().to_string(),
426            declared_schema,
427            supported_schema: DETECTOR_CORPUS_SCHEMA_VERSION,
428            skipped_count: self.forward_skipped,
429            total,
430            detail,
431        }
432    }
433}
434
435fn log_load_summary(state: &DetectorLoadState) {
436    if state.skipped > 0 {
437        // Aggregate into ONE actionable line instead of one warn! per file.
438        // Unknown fields reaching this strict path are same-version typos or
439        // undeclared version skew; a declared bounded-forward corpus is handled
440        // separately below.
441        let version_skew = state
442            .load_errors
443            .iter()
444            .filter(|error| error.contains("unknown field"))
445            .count();
446        let examples = state
447            .load_errors
448            .iter()
449            .take(3)
450            .map(String::as_str)
451            .collect::<Vec<_>>()
452            .join(" | ");
453        if version_skew > 0 {
454            tracing::warn!(
455                "skipped {} detector file(s); {} contain unknown fields while the corpus \
456                 is using the current/legacy strict schema. Fix field typos, or add a \
457                 supported newer `{}` declaration when the fields are intentional. \
458                 Examples: {examples}",
459                state.skipped,
460                version_skew,
461                DETECTOR_CORPUS_MANIFEST_FILE
462            );
463        } else {
464            tracing::warn!(
465                "skipped {} malformed/unreadable detector file(s) - run \
466                 `keyhog detectors --detectors <DIR>` or -vv for the full list. \
467                 Examples: {examples}",
468                state.skipped
469            );
470        }
471    }
472    if state.forward_skipped > 0 {
473        let examples = state
474            .forward_errors
475            .iter()
476            .take(3)
477            .map(String::as_str)
478            .collect::<Vec<_>>()
479            .join(" | ");
480        tracing::warn!(
481            "detector corpus declared a supported forward schema; skipped {} detector \
482             file(s) that use newer fields rather than silently dropping those fields. \
483             Update keyhog for full recall. Examples: {examples}",
484            state.forward_skipped
485        );
486    }
487    if state.legacy_migrations > 0 {
488        tracing::warn!(
489            "migrated {} legacy schema-{} verifier success contract(s) to \
490             status_with_error_backstop; add an explicit success policy and a \
491             schema-{} corpus manifest",
492            state.legacy_migrations,
493            DETECTOR_CORPUS_MIN_SCHEMA_VERSION,
494            DETECTOR_CORPUS_SCHEMA_VERSION
495        );
496    }
497    if state.gate_rejected > 0 {
498        // Law 10: quality-gate rejections are not silent. The per-detector
499        // causes are logged at warn! below; the aggregate is surfaced at
500        // the default level so operators see why the detector set would have
501        // been smaller than expected.
502        tracing::warn!(
503            "quality gate rejected {} detectors (see per-detector warnings above)",
504            state.gate_rejected
505        );
506    }
507    if state.total_warnings > 0 {
508        // Advisory (non-rejecting) quality warnings describe detector-AUTHORING
509        // nits on the already-validated, shipped detector set (e.g. "companion
510        // regex is a pure character class; ALLOWED because within_lines <= 5").
511        // They are build-time/authoring feedback, not an operator signal: the
512        // bundled detectors passed the gate, so re-announcing their advisories
513        // on every user command that loads detectors (`explain`, `detectors`,
514        // a custom `--detectors` dir) is noise that drowns out the real
515        // rejections above. Keep them at debug! (visible with `-vv` /
516        // RUST_LOG=keyhog=debug for authors); errors and gate REJECTIONS stay
517        // loud above (Law 10).
518        tracing::debug!("quality gate: {} advisory warnings", state.total_warnings);
519    }
520}
521
522enum ReadDetectorOutcome {
523    Loaded {
524        path: PathBuf,
525        spec: Box<DetectorSpec>,
526        legacy_migrations: usize,
527    },
528    ForwardSkipped {
529        message: String,
530    },
531    Skipped {
532        message: String,
533    },
534}
535
536fn read_detector_file(path: &Path, compatibility: CorpusCompatibility) -> ReadDetectorOutcome {
537    let contents = match read_detector_toml_file(path) {
538        Ok(contents) => contents,
539        Err(error) => {
540            // LAW10: reporting-only; per-file detail stays at debug! (visible
541            // with -vv), while `log_load_summary` warns and gated loads reject.
542            // One warn! per skipped file floods stderr on a version-skewed or
543            // partly-broken corpus (dozens of near-identical lines before any
544            // finding), which is the opposite of actionable.
545            let message = format!("failed to read {}: {}", path.display(), error);
546            tracing::debug!(
547                detector_path = %path.display(),
548                error = %error,
549                "skipping detector - unreadable file" // LAW10: aggregate warning surfaces the skipped file count and examples; gated loads reject
550            );
551            return ReadDetectorOutcome::Skipped { message };
552        }
553    };
554
555    match toml::from_str::<DetectorFile>(&contents) {
556        Ok(mut file) => {
557            let legacy_migrations =
558                if compatibility.schema_version == DETECTOR_CORPUS_MIN_SCHEMA_VERSION {
559                    migrate_legacy_success_policies(&mut file.detector)
560                } else {
561                    0
562                };
563            ReadDetectorOutcome::Loaded {
564                path: path.to_path_buf(),
565                spec: Box::new(file.detector),
566                legacy_migrations,
567            }
568        }
569        Err(error) => {
570            let unknown_field = error.to_string().contains("unknown field");
571            if compatibility.permits_forward_unknown_fields && unknown_field {
572                let message = format!(
573                    "skipped {} under declared detector corpus schema {} because it uses \
574                     a field unknown to schema {}: {}. Fix: update keyhog to load this detector",
575                    path.display(),
576                    compatibility.schema_version,
577                    DETECTOR_CORPUS_SCHEMA_VERSION,
578                    error
579                );
580                tracing::warn!(
581                    detector_path = %path.display(),
582                    declared_schema = compatibility.schema_version,
583                    supported_schema = DETECTOR_CORPUS_SCHEMA_VERSION,
584                    error = %error,
585                    "skipping forward-schema detector without dropping unknown fields"
586                );
587                return ReadDetectorOutcome::ForwardSkipped { message };
588            }
589            let message = format!(
590                "failed to parse {} under detector corpus schema {}: {}. Fix: correct \
591                 misspelled or invalid detector fields; only a corpus manifest declaring \
592                 a supported newer schema permits an unknown future field",
593                path.display(),
594                compatibility.schema_version,
595                error
596            );
597            // LAW10: the default-level aggregate warning surfaces every skip,
598            // and gated loads return DetectorCorpusRejected with this detail.
599            tracing::debug!(
600                detector_path = %path.display(),
601                schema_version = compatibility.schema_version,
602                error = %error,
603                "skipping detector - TOML parse failed"
604            );
605            ReadDetectorOutcome::Skipped { message }
606        }
607    }
608}
609
610fn should_reject_detector(
611    spec: &DetectorSpec,
612    path: &Path,
613    enforce_gate: bool,
614    gate_rejected: &mut usize,
615    gate_errors: &mut Vec<String>,
616    total_warnings: &mut usize,
617) -> bool {
618    let mut has_errors = false;
619    let mut detector_errors = Vec::new();
620    for issue in validate_detector(spec) {
621        match issue {
622            QualityIssue::Warning(warning) => {
623                // Advisory only - the detector still loads and scans. This is
624                // authoring feedback (see the aggregate at debug! in
625                // `log_load_summary`), so keep it at debug! to stay out of
626                // user-facing command output; errors below stay loud (Law 10).
627                tracing::debug!(detector_path = %path.display(), "quality: {} - {}", spec.id, warning);
628                *total_warnings += 1;
629            }
630            QualityIssue::Error(error) => {
631                // Law 10: a detector that fails the quality gate must not be
632                // silently loaded. The warning names the detector and the
633                // issue so the author can fix it; when enforce_gate is true
634                // the detector is rejected below.
635                tracing::warn!(
636                    detector_path = %path.display(),
637                    "detector quality error: {}: {}",
638                    spec.id,
639                    error
640                );
641                detector_errors.push(format!("{}: {}: {}", path.display(), spec.id, error));
642                has_errors = true;
643            }
644        }
645    }
646
647    if has_errors && enforce_gate {
648        *gate_rejected += 1;
649        gate_errors.extend(detector_errors);
650        return true;
651    }
652
653    false
654}
655
656/// Load a set of detectors from a TOML string.
657///
658/// This is primarily used for dynamic detector injection and tests that need
659/// an in-memory detector corpus.
660pub(crate) fn load_detectors_from_str(toml_str: &str) -> Result<Vec<DetectorSpec>, SpecError> {
661    let file: DetectorFile = toml::from_str(toml_str).map_err(|e| SpecError::InvalidToml {
662        path: PathBuf::from("<string>"),
663        source: e,
664    })?;
665    Ok(vec![file.detector])
666}