Skip to main content

sentry_options_validation/
lib.rs

1//! Schema validation library for sentry-options
2//!
3//! This library provides schema loading and validation for sentry-options.
4//! Schemas are loaded once and stored in Arc for efficient sharing.
5//! Values are validated against schemas as complete objects.
6
7use serde_json::Value;
8use serde_json::json;
9use std::collections::HashMap;
10use std::fs;
11use std::panic::{self, AssertUnwindSafe};
12use std::path::{Path, PathBuf};
13use std::sync::RwLock;
14use std::sync::{
15    Arc,
16    atomic::{AtomicBool, Ordering},
17};
18use std::thread::{self, JoinHandle};
19use std::time::Duration;
20
21/// Embedded meta-schema for validating sentry-options schema files
22const NAMESPACE_SCHEMA_JSON: &str = include_str!("namespace-schema.json");
23const SCHEMA_FILE_NAME: &str = "schema.json";
24const VALUES_FILE_NAME: &str = "values.json";
25
26/// Time between file polls in seconds
27const POLLING_DELAY: u64 = 5;
28
29/// Production path where options are deployed via config map
30pub const PRODUCTION_OPTIONS_DIR: &str = "/etc/sentry-options";
31
32/// Local fallback path for development
33pub const LOCAL_OPTIONS_DIR: &str = "sentry-options";
34
35/// Environment variable to override options directory
36pub const OPTIONS_DIR_ENV: &str = "SENTRY_OPTIONS_DIR";
37
38/// Resolve options directory using fallback chain:
39/// 1. `SENTRY_OPTIONS_DIR` env var (if set)
40/// 2. `/etc/sentry-options` (if exists)
41/// 3. `sentry-options/` (local fallback)
42pub fn resolve_options_dir() -> PathBuf {
43    if let Ok(dir) = std::env::var(OPTIONS_DIR_ENV) {
44        return PathBuf::from(dir);
45    }
46
47    let prod_path = PathBuf::from(PRODUCTION_OPTIONS_DIR);
48    if prod_path.exists() {
49        return prod_path;
50    }
51
52    PathBuf::from(LOCAL_OPTIONS_DIR)
53}
54
55/// Result type for validation operations
56pub type ValidationResult<T> = Result<T, ValidationError>;
57
58/// A map of option values keyed by their namespace
59pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
60
61/// Errors that can occur during schema and value validation
62#[derive(Debug, thiserror::Error)]
63pub enum ValidationError {
64    #[error("Schema error in {file}: {message}")]
65    SchemaError { file: PathBuf, message: String },
66
67    #[error("Value error for {namespace}: {errors}")]
68    ValueError { namespace: String, errors: String },
69
70    #[error("Unknown namespace: {0}")]
71    UnknownNamespace(String),
72
73    #[error("Internal error: {0}")]
74    InternalError(String),
75
76    #[error("Failed to read file: {0}")]
77    FileRead(#[from] std::io::Error),
78
79    #[error("Failed to parse JSON: {0}")]
80    JSONParse(#[from] serde_json::Error),
81
82    #[error("{} validation error(s)", .0.len())]
83    ValidationErrors(Vec<ValidationError>),
84
85    #[error("Invalid {label} '{name}': {reason}")]
86    InvalidName {
87        label: String,
88        name: String,
89        reason: String,
90    },
91}
92
93/// Validate a name component is valid for K8s (lowercase alphanumeric, '-', '.')
94pub fn validate_k8s_name_component(name: &str, label: &str) -> ValidationResult<()> {
95    if let Some(c) = name
96        .chars()
97        .find(|&c| !matches!(c, 'a'..='z' | '0'..='9' | '-' | '.'))
98    {
99        return Err(ValidationError::InvalidName {
100            label: label.to_string(),
101            name: name.to_string(),
102            reason: format!(
103                "character '{}' not allowed. Use lowercase alphanumeric, '-', or '.'",
104                c
105            ),
106        });
107    }
108    if !name.starts_with(|c: char| c.is_ascii_alphanumeric())
109        || !name.ends_with(|c: char| c.is_ascii_alphanumeric())
110    {
111        return Err(ValidationError::InvalidName {
112            label: label.to_string(),
113            name: name.to_string(),
114            reason: "must start and end with alphanumeric".to_string(),
115        });
116    }
117    Ok(())
118}
119
120/// Metadata for a single option in a namespace schema
121#[derive(Debug, Clone)]
122pub struct OptionMetadata {
123    pub option_type: String,
124    pub default: Value,
125}
126
127/// Schema for a namespace, containing validator and option metadata
128pub struct NamespaceSchema {
129    pub namespace: String,
130    pub options: HashMap<String, OptionMetadata>,
131    validator: jsonschema::Validator,
132}
133
134impl NamespaceSchema {
135    /// Validate an entire values object against this schema
136    ///
137    /// # Arguments
138    /// * `values` - JSON object containing option key-value pairs
139    ///
140    /// # Errors
141    /// Returns error if values don't match the schema
142    pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
143        let output = self.validator.evaluate(values);
144        if output.flag().valid {
145            Ok(())
146        } else {
147            let errors: Vec<String> = output.iter_errors().map(|e| e.error.to_string()).collect();
148            Err(ValidationError::ValueError {
149                namespace: self.namespace.clone(),
150                errors: errors.join(", "),
151            })
152        }
153    }
154
155    /// Get the default value for an option key.
156    /// Returns None if the key doesn't exist in the schema.
157    pub fn get_default(&self, key: &str) -> Option<&Value> {
158        self.options.get(key).map(|meta| &meta.default)
159    }
160}
161
162/// Registry for loading and storing schemas
163pub struct SchemaRegistry {
164    schemas: HashMap<String, Arc<NamespaceSchema>>,
165}
166
167impl SchemaRegistry {
168    /// Create a new empty schema registry
169    pub fn new() -> Self {
170        Self {
171            schemas: HashMap::new(),
172        }
173    }
174
175    /// Load schemas from a directory and create a registry
176    ///
177    /// Expects directory structure: `schemas/{namespace}/schema.json`
178    ///
179    /// # Arguments
180    /// * `schemas_dir` - Path to directory containing namespace subdirectories
181    ///
182    /// # Errors
183    /// Returns error if directory doesn't exist or any schema is invalid
184    pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
185        let schemas = Self::load_all_schemas(schemas_dir)?;
186        Ok(Self { schemas })
187    }
188
189    /// Validate an entire values object for a namespace
190    ///
191    /// # Arguments
192    /// * `namespace` - Namespace name
193    /// * `values` - JSON object containing option key-value pairs
194    ///
195    /// # Errors
196    /// Returns error if namespace doesn't exist or values don't match schema
197    pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
198        let schema = self
199            .schemas
200            .get(namespace)
201            .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
202
203        schema.validate_values(values)
204    }
205
206    /// Load all schemas from a directory
207    fn load_all_schemas(
208        schemas_dir: &Path,
209    ) -> ValidationResult<HashMap<String, Arc<NamespaceSchema>>> {
210        // Compile namespace-schema once for all schemas
211        let namespace_schema_value: Value =
212            serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
213                ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
214            })?;
215        let namespace_validator =
216            jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
217                ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
218            })?;
219
220        let mut schemas = HashMap::new();
221
222        // TODO: Parallelize the loading of schemas for the performance gainz
223        for entry in fs::read_dir(schemas_dir)? {
224            let entry = entry?;
225
226            if !entry.file_type()?.is_dir() {
227                continue;
228            }
229
230            let namespace =
231                entry
232                    .file_name()
233                    .into_string()
234                    .map_err(|_| ValidationError::SchemaError {
235                        file: entry.path(),
236                        message: "Directory name contains invalid UTF-8".to_string(),
237                    })?;
238
239            validate_k8s_name_component(&namespace, "namespace name")?;
240
241            let schema_file = entry.path().join(SCHEMA_FILE_NAME);
242            let schema = Self::load_schema(&schema_file, &namespace, &namespace_validator)?;
243            schemas.insert(namespace, schema);
244        }
245
246        Ok(schemas)
247    }
248
249    /// Load a schema from a file
250    fn load_schema(
251        path: &Path,
252        namespace: &str,
253        namespace_validator: &jsonschema::Validator,
254    ) -> ValidationResult<Arc<NamespaceSchema>> {
255        let file = fs::File::open(path)?;
256        let schema_data: Value = serde_json::from_reader(file)?;
257
258        Self::validate_with_namespace_schema(&schema_data, path, namespace_validator)?;
259        Self::parse_schema(schema_data, namespace, path)
260    }
261
262    /// Validate a schema against the namespace-schema
263    fn validate_with_namespace_schema(
264        schema_data: &Value,
265        path: &Path,
266        namespace_validator: &jsonschema::Validator,
267    ) -> ValidationResult<()> {
268        let output = namespace_validator.evaluate(schema_data);
269
270        if output.flag().valid {
271            Ok(())
272        } else {
273            let errors: Vec<String> = output
274                .iter_errors()
275                .map(|e| format!("Error: {}", e.error))
276                .collect();
277
278            Err(ValidationError::SchemaError {
279                file: path.to_path_buf(),
280                message: format!("Schema validation failed:\n{}", errors.join("\n")),
281            })
282        }
283    }
284
285    /// Validate that a default value matches its declared type using jsonschema
286    fn validate_default_type(
287        property_name: &str,
288        property_type: &str,
289        default_value: &Value,
290        path: &Path,
291    ) -> ValidationResult<()> {
292        // Build a mini JSON Schema for just this type
293        let type_schema = serde_json::json!({
294            "type": property_type
295        });
296
297        // Validate the default value against the type
298        jsonschema::validate(&type_schema, default_value).map_err(|e| {
299            ValidationError::SchemaError {
300                file: path.to_path_buf(),
301                message: format!(
302                    "Property '{}': default value does not match type '{}': {}",
303                    property_name, property_type, e
304                ),
305            }
306        })?;
307
308        Ok(())
309    }
310
311    /// Parse a schema JSON into NamespaceSchema
312    fn parse_schema(
313        mut schema: Value,
314        namespace: &str,
315        path: &Path,
316    ) -> ValidationResult<Arc<NamespaceSchema>> {
317        // Inject additionalProperties: false to reject unknown options
318        if let Some(obj) = schema.as_object_mut() {
319            obj.insert("additionalProperties".to_string(), json!(false));
320        }
321
322        // Use the schema file directly as the validator
323        let validator =
324            jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
325                file: path.to_path_buf(),
326                message: format!("Failed to compile validator: {}", e),
327            })?;
328
329        // Extract option metadata and validate types
330        let mut options = HashMap::new();
331        if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
332            for (prop_name, prop_value) in properties {
333                if let (Some(prop_type), Some(default_value)) = (
334                    prop_value.get("type").and_then(|t| t.as_str()),
335                    prop_value.get("default"),
336                ) {
337                    Self::validate_default_type(prop_name, prop_type, default_value, path)?;
338                    options.insert(
339                        prop_name.clone(),
340                        OptionMetadata {
341                            option_type: prop_type.to_string(),
342                            default: default_value.clone(),
343                        },
344                    );
345                }
346            }
347        }
348
349        Ok(Arc::new(NamespaceSchema {
350            namespace: namespace.to_string(),
351            options,
352            validator,
353        }))
354    }
355
356    /// Get a namespace schema by name
357    pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
358        self.schemas.get(namespace)
359    }
360
361    /// Get all loaded schemas (for schema evolution validation)
362    pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
363        &self.schemas
364    }
365
366    /// Load and validate JSON values from a directory.
367    /// Expects structure: `{values_dir}/{namespace}/values.json`
368    /// Values file must have format: `{"options": {"key": value, ...}}`
369    /// Skips namespaces without a values.json file.
370    pub fn load_values_json(&self, values_dir: &Path) -> ValidationResult<ValuesByNamespace> {
371        let mut all_values = HashMap::new();
372
373        for namespace in self.schemas.keys() {
374            let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
375
376            if !values_file.exists() {
377                continue;
378            }
379
380            let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
381
382            let values = parsed
383                .get("options")
384                .ok_or_else(|| ValidationError::ValueError {
385                    namespace: namespace.clone(),
386                    errors: "values.json must have an 'options' key".to_string(),
387                })?;
388
389            self.validate_values(namespace, values)?;
390
391            if let Value::Object(obj) = values.clone() {
392                let ns_values: HashMap<String, Value> = obj.into_iter().collect();
393                all_values.insert(namespace.clone(), ns_values);
394            }
395        }
396
397        Ok(all_values)
398    }
399}
400
401impl Default for SchemaRegistry {
402    fn default() -> Self {
403        Self::new()
404    }
405}
406
407/// Watches the values directory for changes, reloading if there are any.
408/// If the directory does not exist we do not panic
409///
410/// Does not do an initial fetch, assumes the caller has already loaded values.
411/// Child thread may panic if we run out of memory or cannot create more threads.
412///
413/// Uses polling for now, could use `inotify` or similar later on.
414///
415/// Some important notes:
416/// - If the thread panics and dies, there is no built in mechanism to catch it and restart
417/// - If a config map is unmounted, we won't reload until the next file modification (because we don't catch the deletion event)
418/// - If any namespace fails validation, we keep all old values (even the namespaces that passed validation)
419/// - If we have a steady stream of readers our writer may starve for a while trying to acquire the lock
420/// - stop() will block until the thread gets joined
421pub struct ValuesWatcher {
422    stop_signal: Arc<AtomicBool>,
423    thread: Option<JoinHandle<()>>,
424}
425
426impl ValuesWatcher {
427    /// Creates a new ValuesWatcher struct and spins up the watcher thread
428    pub fn new(
429        values_path: &Path,
430        registry: Arc<SchemaRegistry>,
431        values: Arc<RwLock<ValuesByNamespace>>,
432    ) -> ValidationResult<Self> {
433        // output an error but keep passing
434        if fs::metadata(values_path).is_err() {
435            eprintln!("Values directory does not exist: {}", values_path.display());
436        }
437
438        let stop_signal = Arc::new(AtomicBool::new(false));
439
440        let thread_signal = Arc::clone(&stop_signal);
441        let thread_path = values_path.to_path_buf();
442        let thread_registry = Arc::clone(&registry);
443        let thread_values = Arc::clone(&values);
444        let thread = thread::Builder::new()
445            .name("sentry-options-watcher".into())
446            .spawn(move || {
447                let result = panic::catch_unwind(AssertUnwindSafe(|| {
448                    Self::run(thread_signal, thread_path, thread_registry, thread_values);
449                }));
450                if let Err(e) = result {
451                    eprintln!("Watcher thread panicked with: {:?}", e);
452                }
453            })?;
454
455        Ok(Self {
456            stop_signal,
457            thread: Some(thread),
458        })
459    }
460
461    /// Reloads the values if the modified time has changed.
462    ///
463    /// Continuously polls the values directory and reloads all values
464    /// if any modification is detected.
465    fn run(
466        stop_signal: Arc<AtomicBool>,
467        values_path: PathBuf,
468        registry: Arc<SchemaRegistry>,
469        values: Arc<RwLock<ValuesByNamespace>>,
470    ) {
471        let mut last_mtime = Self::get_mtime(&values_path);
472
473        while !stop_signal.load(Ordering::Relaxed) {
474            // does not reload values if get_mtime fails
475            if let Some(current_mtime) = Self::get_mtime(&values_path)
476                && Some(current_mtime) != last_mtime
477            {
478                Self::reload_values(&values_path, &registry, &values);
479                last_mtime = Some(current_mtime);
480            }
481
482            thread::sleep(Duration::from_secs(POLLING_DELAY));
483        }
484    }
485
486    /// Get the most recent modification time across all namespace values.json files
487    /// Returns None if no valid values files are found
488    fn get_mtime(values_dir: &Path) -> Option<std::time::SystemTime> {
489        let mut latest_mtime = None;
490
491        let entries = match fs::read_dir(values_dir) {
492            Ok(e) => e,
493            Err(e) => {
494                eprintln!("Failed to read directory {}: {}", values_dir.display(), e);
495                return None;
496            }
497        };
498
499        for entry in entries.flatten() {
500            // skip if not a dir
501            if !entry
502                .file_type()
503                .map(|file_type| file_type.is_dir())
504                .unwrap_or(false)
505            {
506                continue;
507            }
508
509            let values_file = entry.path().join(VALUES_FILE_NAME);
510            if let Ok(metadata) = fs::metadata(&values_file)
511                && let Ok(mtime) = metadata.modified()
512                && latest_mtime.is_none_or(|latest| mtime > latest)
513            {
514                latest_mtime = Some(mtime);
515            }
516        }
517
518        latest_mtime
519    }
520
521    /// Reload values from disk, validate them, and update the shared map
522    fn reload_values(
523        values_path: &Path,
524        registry: &SchemaRegistry,
525        values: &Arc<RwLock<ValuesByNamespace>>,
526    ) {
527        match registry.load_values_json(values_path) {
528            Ok(new_values) => {
529                Self::update_values(values, new_values);
530                // TODO: add some logging here so we know when options refresh
531            }
532            Err(e) => {
533                eprintln!(
534                    "Failed to reload values from {}: {}",
535                    values_path.display(),
536                    e
537                );
538            }
539        }
540    }
541
542    /// Update the values map with the new values
543    fn update_values(values: &Arc<RwLock<ValuesByNamespace>>, new_values: ValuesByNamespace) {
544        // safe to unwrap, we only have one thread and if it panics we die anyways
545        let mut guard = values.write().unwrap();
546        *guard = new_values;
547    }
548
549    /// Stops the watcher thread, waiting for it to join.
550    /// May take up to POLLING_DELAY seconds
551    pub fn stop(&mut self) {
552        self.stop_signal.store(true, Ordering::Relaxed);
553        if let Some(thread) = self.thread.take() {
554            let _ = thread.join();
555        }
556    }
557
558    /// Returns whether the watcher thread is still running
559    pub fn is_alive(&self) -> bool {
560        self.thread.as_ref().is_some_and(|t| !t.is_finished())
561    }
562}
563
564impl Drop for ValuesWatcher {
565    fn drop(&mut self) {
566        self.stop();
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use tempfile::TempDir;
574
575    fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
576        let schema_dir = temp_dir.path().join(namespace);
577        fs::create_dir_all(&schema_dir).unwrap();
578        let schema_file = schema_dir.join("schema.json");
579        fs::write(&schema_file, schema_json).unwrap();
580        schema_file
581    }
582
583    #[test]
584    fn test_validate_k8s_name_component_valid() {
585        assert!(validate_k8s_name_component("relay", "namespace").is_ok());
586        assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
587        assert!(validate_k8s_name_component("my.service", "namespace").is_ok());
588        assert!(validate_k8s_name_component("a1-b2.c3", "namespace").is_ok());
589    }
590
591    #[test]
592    fn test_validate_k8s_name_component_rejects_uppercase() {
593        let result = validate_k8s_name_component("MyService", "namespace");
594        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
595        assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
596    }
597
598    #[test]
599    fn test_validate_k8s_name_component_rejects_underscore() {
600        let result = validate_k8s_name_component("my_service", "target");
601        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
602        assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
603    }
604
605    #[test]
606    fn test_validate_k8s_name_component_rejects_leading_hyphen() {
607        let result = validate_k8s_name_component("-service", "namespace");
608        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
609        assert!(
610            result
611                .unwrap_err()
612                .to_string()
613                .contains("start and end with alphanumeric")
614        );
615    }
616
617    #[test]
618    fn test_validate_k8s_name_component_rejects_trailing_dot() {
619        let result = validate_k8s_name_component("service.", "namespace");
620        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
621        assert!(
622            result
623                .unwrap_err()
624                .to_string()
625                .contains("start and end with alphanumeric")
626        );
627    }
628
629    #[test]
630    fn test_load_schema_valid() {
631        let temp_dir = TempDir::new().unwrap();
632        create_test_schema(
633            &temp_dir,
634            "test",
635            r#"{
636                "version": "1.0",
637                "type": "object",
638                "properties": {
639                    "test-key": {
640                        "type": "string",
641                        "default": "test",
642                        "description": "Test option"
643                    }
644                }
645            }"#,
646        );
647
648        SchemaRegistry::from_directory(temp_dir.path()).unwrap();
649    }
650
651    #[test]
652    fn test_load_schema_missing_version() {
653        let temp_dir = TempDir::new().unwrap();
654        create_test_schema(
655            &temp_dir,
656            "test",
657            r#"{
658                "type": "object",
659                "properties": {}
660            }"#,
661        );
662
663        let result = SchemaRegistry::from_directory(temp_dir.path());
664        assert!(result.is_err());
665        match result {
666            Err(ValidationError::SchemaError { message, .. }) => {
667                assert!(message.contains(
668                    "Schema validation failed:
669Error: \"version\" is a required property"
670                ));
671            }
672            _ => panic!("Expected SchemaError for missing version"),
673        }
674    }
675
676    #[test]
677    fn test_unknown_namespace() {
678        let temp_dir = TempDir::new().unwrap();
679        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
680
681        let result = registry.validate_values("unknown", &json!({}));
682        assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
683    }
684
685    #[test]
686    fn test_multiple_namespaces() {
687        let temp_dir = TempDir::new().unwrap();
688        create_test_schema(
689            &temp_dir,
690            "ns1",
691            r#"{
692                "version": "1.0",
693                "type": "object",
694                "properties": {
695                    "opt1": {
696                        "type": "string",
697                        "default": "default1",
698                        "description": "First option"
699                    }
700                }
701            }"#,
702        );
703        create_test_schema(
704            &temp_dir,
705            "ns2",
706            r#"{
707                "version": "2.0",
708                "type": "object",
709                "properties": {
710                    "opt2": {
711                        "type": "integer",
712                        "default": 42,
713                        "description": "Second option"
714                    }
715                }
716            }"#,
717        );
718
719        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
720        assert!(registry.schemas.contains_key("ns1"));
721        assert!(registry.schemas.contains_key("ns2"));
722    }
723
724    #[test]
725    fn test_invalid_default_type() {
726        let temp_dir = TempDir::new().unwrap();
727        create_test_schema(
728            &temp_dir,
729            "test",
730            r#"{
731                "version": "1.0",
732                "type": "object",
733                "properties": {
734                    "bad-default": {
735                        "type": "integer",
736                        "default": "not-a-number",
737                        "description": "A bad default value"
738                    }
739                }
740            }"#,
741        );
742
743        let result = SchemaRegistry::from_directory(temp_dir.path());
744        assert!(result.is_err());
745        match result {
746            Err(ValidationError::SchemaError { message, .. }) => {
747                assert!(message.contains("Property 'bad-default': default value does not match type 'integer': \"not-a-number\" is not of type \"integer\""));
748            }
749            _ => panic!("Expected SchemaError for invalid default type"),
750        }
751    }
752
753    #[test]
754    fn test_extra_properties() {
755        let temp_dir = TempDir::new().unwrap();
756        create_test_schema(
757            &temp_dir,
758            "test",
759            r#"{
760                "version": "1.0",
761                "type": "object",
762                "properties": {
763                    "bad-property": {
764                        "type": "integer",
765                        "default": 0,
766                        "description": "Test property",
767                        "extra": "property"
768                    }
769                }
770            }"#,
771        );
772
773        let result = SchemaRegistry::from_directory(temp_dir.path());
774        assert!(result.is_err());
775        match result {
776            Err(ValidationError::SchemaError { message, .. }) => {
777                assert!(
778                    message
779                        .contains("Additional properties are not allowed ('extra' was unexpected)")
780                );
781            }
782            _ => panic!("Expected SchemaError for extra properties"),
783        }
784    }
785
786    #[test]
787    fn test_missing_description() {
788        let temp_dir = TempDir::new().unwrap();
789        create_test_schema(
790            &temp_dir,
791            "test",
792            r#"{
793                "version": "1.0",
794                "type": "object",
795                "properties": {
796                    "missing-desc": {
797                        "type": "string",
798                        "default": "test"
799                    }
800                }
801            }"#,
802        );
803
804        let result = SchemaRegistry::from_directory(temp_dir.path());
805        assert!(result.is_err());
806        match result {
807            Err(ValidationError::SchemaError { message, .. }) => {
808                assert!(message.contains("\"description\" is a required property"));
809            }
810            _ => panic!("Expected SchemaError for missing description"),
811        }
812    }
813
814    #[test]
815    fn test_invalid_directory_structure() {
816        let temp_dir = TempDir::new().unwrap();
817        // Create a namespace directory without schema.json file
818        let schema_dir = temp_dir.path().join("missing-schema");
819        fs::create_dir_all(&schema_dir).unwrap();
820
821        let result = SchemaRegistry::from_directory(temp_dir.path());
822        assert!(result.is_err());
823        match result {
824            Err(ValidationError::FileRead(..)) => {
825                // Expected error when schema.json file is missing
826            }
827            _ => panic!("Expected FileRead error for missing schema.json"),
828        }
829    }
830
831    #[test]
832    fn test_get_default() {
833        let temp_dir = TempDir::new().unwrap();
834        create_test_schema(
835            &temp_dir,
836            "test",
837            r#"{
838                "version": "1.0",
839                "type": "object",
840                "properties": {
841                    "string_opt": {
842                        "type": "string",
843                        "default": "hello",
844                        "description": "A string option"
845                    },
846                    "int_opt": {
847                        "type": "integer",
848                        "default": 42,
849                        "description": "An integer option"
850                    }
851                }
852            }"#,
853        );
854
855        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
856        let schema = registry.get("test").unwrap();
857
858        assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
859        assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
860        assert_eq!(schema.get_default("unknown"), None);
861    }
862
863    #[test]
864    fn test_validate_values_valid() {
865        let temp_dir = TempDir::new().unwrap();
866        create_test_schema(
867            &temp_dir,
868            "test",
869            r#"{
870                "version": "1.0",
871                "type": "object",
872                "properties": {
873                    "enabled": {
874                        "type": "boolean",
875                        "default": false,
876                        "description": "Enable feature"
877                    }
878                }
879            }"#,
880        );
881
882        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
883        let result = registry.validate_values("test", &json!({"enabled": true}));
884        assert!(result.is_ok());
885    }
886
887    #[test]
888    fn test_validate_values_invalid_type() {
889        let temp_dir = TempDir::new().unwrap();
890        create_test_schema(
891            &temp_dir,
892            "test",
893            r#"{
894                "version": "1.0",
895                "type": "object",
896                "properties": {
897                    "count": {
898                        "type": "integer",
899                        "default": 0,
900                        "description": "Count"
901                    }
902                }
903            }"#,
904        );
905
906        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
907        let result = registry.validate_values("test", &json!({"count": "not a number"}));
908        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
909    }
910
911    #[test]
912    fn test_validate_values_unknown_option() {
913        let temp_dir = TempDir::new().unwrap();
914        create_test_schema(
915            &temp_dir,
916            "test",
917            r#"{
918                "version": "1.0",
919                "type": "object",
920                "properties": {
921                    "known_option": {
922                        "type": "string",
923                        "default": "default",
924                        "description": "A known option"
925                    }
926                }
927            }"#,
928        );
929
930        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
931
932        // Valid known option should pass
933        let result = registry.validate_values("test", &json!({"known_option": "value"}));
934        assert!(result.is_ok());
935
936        // Unknown option should fail
937        let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
938        assert!(result.is_err());
939        match result {
940            Err(ValidationError::ValueError { errors, .. }) => {
941                assert!(errors.contains("Additional properties are not allowed"));
942            }
943            _ => panic!("Expected ValueError for unknown option"),
944        }
945    }
946
947    #[test]
948    fn test_load_values_json_valid() {
949        let temp_dir = TempDir::new().unwrap();
950        let schemas_dir = temp_dir.path().join("schemas");
951        let values_dir = temp_dir.path().join("values");
952
953        let schema_dir = schemas_dir.join("test");
954        fs::create_dir_all(&schema_dir).unwrap();
955        fs::write(
956            schema_dir.join("schema.json"),
957            r#"{
958                "version": "1.0",
959                "type": "object",
960                "properties": {
961                    "enabled": {
962                        "type": "boolean",
963                        "default": false,
964                        "description": "Enable feature"
965                    },
966                    "name": {
967                        "type": "string",
968                        "default": "default",
969                        "description": "Name"
970                    },
971                    "count": {
972                        "type": "integer",
973                        "default": 0,
974                        "description": "Count"
975                    },
976                    "rate": {
977                        "type": "number",
978                        "default": 0.0,
979                        "description": "Rate"
980                    }
981                }
982            }"#,
983        )
984        .unwrap();
985
986        let test_values_dir = values_dir.join("test");
987        fs::create_dir_all(&test_values_dir).unwrap();
988        fs::write(
989            test_values_dir.join("values.json"),
990            r#"{
991                "options": {
992                    "enabled": true,
993                    "name": "test-name",
994                    "count": 42,
995                    "rate": 0.75
996                }
997            }"#,
998        )
999        .unwrap();
1000
1001        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1002        let values = registry.load_values_json(&values_dir).unwrap();
1003
1004        assert_eq!(values.len(), 1);
1005        assert_eq!(values["test"]["enabled"], json!(true));
1006        assert_eq!(values["test"]["name"], json!("test-name"));
1007        assert_eq!(values["test"]["count"], json!(42));
1008        assert_eq!(values["test"]["rate"], json!(0.75));
1009    }
1010
1011    #[test]
1012    fn test_load_values_json_nonexistent_dir() {
1013        let temp_dir = TempDir::new().unwrap();
1014        create_test_schema(
1015            &temp_dir,
1016            "test",
1017            r#"{"version": "1.0", "type": "object", "properties": {}}"#,
1018        );
1019
1020        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1021        let values = registry
1022            .load_values_json(&temp_dir.path().join("nonexistent"))
1023            .unwrap();
1024
1025        // No values.json files found, returns empty
1026        assert!(values.is_empty());
1027    }
1028
1029    #[test]
1030    fn test_load_values_json_skips_missing_values_file() {
1031        let temp_dir = TempDir::new().unwrap();
1032        let schemas_dir = temp_dir.path().join("schemas");
1033        let values_dir = temp_dir.path().join("values");
1034
1035        // Create two schemas
1036        let schema_dir1 = schemas_dir.join("with-values");
1037        fs::create_dir_all(&schema_dir1).unwrap();
1038        fs::write(
1039            schema_dir1.join("schema.json"),
1040            r#"{
1041                "version": "1.0",
1042                "type": "object",
1043                "properties": {
1044                    "opt": {"type": "string", "default": "x", "description": "Opt"}
1045                }
1046            }"#,
1047        )
1048        .unwrap();
1049
1050        let schema_dir2 = schemas_dir.join("without-values");
1051        fs::create_dir_all(&schema_dir2).unwrap();
1052        fs::write(
1053            schema_dir2.join("schema.json"),
1054            r#"{
1055                "version": "1.0",
1056                "type": "object",
1057                "properties": {
1058                    "opt": {"type": "string", "default": "x", "description": "Opt"}
1059                }
1060            }"#,
1061        )
1062        .unwrap();
1063
1064        // Only create values for one namespace
1065        let with_values_dir = values_dir.join("with-values");
1066        fs::create_dir_all(&with_values_dir).unwrap();
1067        fs::write(
1068            with_values_dir.join("values.json"),
1069            r#"{"options": {"opt": "y"}}"#,
1070        )
1071        .unwrap();
1072
1073        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1074        let values = registry.load_values_json(&values_dir).unwrap();
1075
1076        assert_eq!(values.len(), 1);
1077        assert!(values.contains_key("with-values"));
1078        assert!(!values.contains_key("without-values"));
1079    }
1080
1081    #[test]
1082    fn test_load_values_json_rejects_wrong_type() {
1083        let temp_dir = TempDir::new().unwrap();
1084        let schemas_dir = temp_dir.path().join("schemas");
1085        let values_dir = temp_dir.path().join("values");
1086
1087        let schema_dir = schemas_dir.join("test");
1088        fs::create_dir_all(&schema_dir).unwrap();
1089        fs::write(
1090            schema_dir.join("schema.json"),
1091            r#"{
1092                "version": "1.0",
1093                "type": "object",
1094                "properties": {
1095                    "count": {"type": "integer", "default": 0, "description": "Count"}
1096                }
1097            }"#,
1098        )
1099        .unwrap();
1100
1101        let test_values_dir = values_dir.join("test");
1102        fs::create_dir_all(&test_values_dir).unwrap();
1103        fs::write(
1104            test_values_dir.join("values.json"),
1105            r#"{"options": {"count": "not-a-number"}}"#,
1106        )
1107        .unwrap();
1108
1109        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1110        let result = registry.load_values_json(&values_dir);
1111
1112        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1113    }
1114
1115    mod watcher_tests {
1116        use super::*;
1117        use std::thread;
1118
1119        /// Creates schema and values files for two namespaces: ns1, and ns2
1120        fn setup_watcher_test() -> (TempDir, PathBuf, PathBuf) {
1121            let temp_dir = TempDir::new().unwrap();
1122            let schemas_dir = temp_dir.path().join("schemas");
1123            let values_dir = temp_dir.path().join("values");
1124
1125            let ns1_schema = schemas_dir.join("ns1");
1126            fs::create_dir_all(&ns1_schema).unwrap();
1127            fs::write(
1128                ns1_schema.join("schema.json"),
1129                r#"{
1130                    "version": "1.0",
1131                    "type": "object",
1132                    "properties": {
1133                        "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1134                    }
1135                }"#,
1136            )
1137            .unwrap();
1138
1139            let ns1_values = values_dir.join("ns1");
1140            fs::create_dir_all(&ns1_values).unwrap();
1141            fs::write(
1142                ns1_values.join("values.json"),
1143                r#"{"options": {"enabled": true}}"#,
1144            )
1145            .unwrap();
1146
1147            let ns2_schema = schemas_dir.join("ns2");
1148            fs::create_dir_all(&ns2_schema).unwrap();
1149            fs::write(
1150                ns2_schema.join("schema.json"),
1151                r#"{
1152                    "version": "1.0",
1153                    "type": "object",
1154                    "properties": {
1155                        "count": {"type": "integer", "default": 0, "description": "Count"}
1156                    }
1157                }"#,
1158            )
1159            .unwrap();
1160
1161            let ns2_values = values_dir.join("ns2");
1162            fs::create_dir_all(&ns2_values).unwrap();
1163            fs::write(
1164                ns2_values.join("values.json"),
1165                r#"{"options": {"count": 42}}"#,
1166            )
1167            .unwrap();
1168
1169            (temp_dir, schemas_dir, values_dir)
1170        }
1171
1172        #[test]
1173        fn test_get_mtime_returns_most_recent() {
1174            let (_temp, _schemas, values_dir) = setup_watcher_test();
1175
1176            // Get initial mtime
1177            let mtime1 = ValuesWatcher::get_mtime(&values_dir);
1178            assert!(mtime1.is_some());
1179
1180            // Modify one namespace
1181            thread::sleep(std::time::Duration::from_millis(10));
1182            fs::write(
1183                values_dir.join("ns1").join("values.json"),
1184                r#"{"options": {"enabled": false}}"#,
1185            )
1186            .unwrap();
1187
1188            // Should detect the change
1189            let mtime2 = ValuesWatcher::get_mtime(&values_dir);
1190            assert!(mtime2.is_some());
1191            assert!(mtime2 > mtime1);
1192        }
1193
1194        #[test]
1195        fn test_get_mtime_with_missing_directory() {
1196            let temp = TempDir::new().unwrap();
1197            let nonexistent = temp.path().join("nonexistent");
1198
1199            let mtime = ValuesWatcher::get_mtime(&nonexistent);
1200            assert!(mtime.is_none());
1201        }
1202
1203        #[test]
1204        fn test_reload_values_updates_map() {
1205            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1206
1207            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1208            let initial_values = registry.load_values_json(&values_dir).unwrap();
1209            let values = Arc::new(RwLock::new(initial_values));
1210
1211            // ensure initial values are correct
1212            {
1213                let guard = values.read().unwrap();
1214                assert_eq!(guard["ns1"]["enabled"], json!(true));
1215                assert_eq!(guard["ns2"]["count"], json!(42));
1216            }
1217
1218            // modify
1219            fs::write(
1220                values_dir.join("ns1").join("values.json"),
1221                r#"{"options": {"enabled": false}}"#,
1222            )
1223            .unwrap();
1224            fs::write(
1225                values_dir.join("ns2").join("values.json"),
1226                r#"{"options": {"count": 100}}"#,
1227            )
1228            .unwrap();
1229
1230            // force a reload
1231            ValuesWatcher::reload_values(&values_dir, &registry, &values);
1232
1233            // ensure new values are correct
1234            {
1235                let guard = values.read().unwrap();
1236                assert_eq!(guard["ns1"]["enabled"], json!(false));
1237                assert_eq!(guard["ns2"]["count"], json!(100));
1238            }
1239        }
1240
1241        #[test]
1242        fn test_old_values_persist_with_invalid_data() {
1243            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1244
1245            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1246            let initial_values = registry.load_values_json(&values_dir).unwrap();
1247            let values = Arc::new(RwLock::new(initial_values));
1248
1249            let initial_enabled = {
1250                let guard = values.read().unwrap();
1251                guard["ns1"]["enabled"].clone()
1252            };
1253
1254            // won't pass validation
1255            fs::write(
1256                values_dir.join("ns1").join("values.json"),
1257                r#"{"options": {"enabled": "not-a-boolean"}}"#,
1258            )
1259            .unwrap();
1260
1261            ValuesWatcher::reload_values(&values_dir, &registry, &values);
1262
1263            // ensure old value persists
1264            {
1265                let guard = values.read().unwrap();
1266                assert_eq!(guard["ns1"]["enabled"], initial_enabled);
1267            }
1268        }
1269
1270        #[test]
1271        fn test_watcher_creation_and_termination() {
1272            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1273
1274            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1275            let initial_values = registry.load_values_json(&values_dir).unwrap();
1276            let values = Arc::new(RwLock::new(initial_values));
1277
1278            let mut watcher =
1279                ValuesWatcher::new(&values_dir, Arc::clone(&registry), Arc::clone(&values))
1280                    .expect("Failed to create watcher");
1281
1282            assert!(watcher.is_alive());
1283            watcher.stop();
1284            assert!(!watcher.is_alive());
1285        }
1286    }
1287}