Skip to main content

maincopy_server/config/
diagnostic.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5const HOST_AUTHORITY: &str = "host";
6
7/// Stable machine-readable host configuration validation codes.
8#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
9#[serde(rename_all = "snake_case")]
10pub enum ConfigurationValidationCode {
11    WorkingDirectoryUnavailable,
12    HostFileUnreadable,
13    HostDocumentTooLarge,
14    HostTextInvalidUtf8,
15    HostTomlInvalid,
16    AdminBindInvalid,
17    MetricsBindInvalid,
18    AdminOriginInvalid,
19    SourceModeConflict,
20    PathInvalid,
21    SecretReferenceInvalid,
22    LimitOutOfRange,
23    DurationInvalid,
24    PageSizeInvalid,
25    ContentLimitRelationshipInvalid,
26}
27
28impl ConfigurationValidationCode {
29    pub const fn as_str(self) -> &'static str {
30        match self {
31            Self::WorkingDirectoryUnavailable => "working_directory_unavailable",
32            Self::HostFileUnreadable => "host_file_unreadable",
33            Self::HostDocumentTooLarge => "host_document_too_large",
34            Self::HostTextInvalidUtf8 => "host_text_invalid_utf8",
35            Self::HostTomlInvalid => "host_toml_invalid",
36            Self::AdminBindInvalid => "admin_bind_invalid",
37            Self::MetricsBindInvalid => "metrics_bind_invalid",
38            Self::AdminOriginInvalid => "admin_origin_invalid",
39            Self::SourceModeConflict => "source_mode_conflict",
40            Self::PathInvalid => "path_invalid",
41            Self::SecretReferenceInvalid => "secret_reference_invalid",
42            Self::LimitOutOfRange => "limit_out_of_range",
43            Self::DurationInvalid => "duration_invalid",
44            Self::PageSizeInvalid => "page_size_invalid",
45            Self::ContentLimitRelationshipInvalid => "content_limit_relationship_invalid",
46        }
47    }
48}
49
50impl fmt::Display for ConfigurationValidationCode {
51    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
52        formatter.write_str(self.as_str())
53    }
54}
55
56/// One redaction-safe host configuration diagnostic.
57#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
58#[non_exhaustive]
59pub struct ConfigurationDiagnostic {
60    pub authority: &'static str,
61    pub field: Box<str>,
62    pub code: ConfigurationValidationCode,
63    pub message: &'static str,
64    pub line: Option<usize>,
65    pub column: Option<usize>,
66}
67
68impl ConfigurationDiagnostic {
69    pub(crate) fn new(
70        field: impl Into<Box<str>>,
71        code: ConfigurationValidationCode,
72        message: &'static str,
73    ) -> Self {
74        Self {
75            authority: HOST_AUTHORITY,
76            field: field.into(),
77            code,
78            message,
79            line: None,
80            column: None,
81        }
82    }
83
84    pub(crate) const fn at(mut self, line: usize, column: usize) -> Self {
85        self.line = Some(line);
86        self.column = Some(column);
87        self
88    }
89}
90
91impl fmt::Display for ConfigurationDiagnostic {
92    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93        write!(formatter, "{}:{}:{}", self.authority, self.field, self.code)?;
94        if let (Some(line), Some(column)) = (self.line, self.column) {
95            write!(formatter, " at {line}:{column}")?;
96        }
97        write!(formatter, ": {}", self.message)
98    }
99}
100
101/// A non-empty, deterministically ordered host configuration error set.
102#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
103pub struct ConfigurationErrors {
104    diagnostics: Vec<ConfigurationDiagnostic>,
105}
106
107impl ConfigurationErrors {
108    pub(crate) fn from_diagnostics(mut diagnostics: Vec<ConfigurationDiagnostic>) -> Self {
109        debug_assert!(!diagnostics.is_empty());
110        diagnostics.sort();
111        diagnostics.dedup();
112        Self { diagnostics }
113    }
114
115    pub fn diagnostics(&self) -> &[ConfigurationDiagnostic] {
116        &self.diagnostics
117    }
118}
119
120impl fmt::Display for ConfigurationErrors {
121    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122        formatter.write_str("configuration validation failed")?;
123        for diagnostic in &self.diagnostics {
124            write!(formatter, "; {diagnostic}")?;
125        }
126        Ok(())
127    }
128}
129
130impl std::error::Error for ConfigurationErrors {}
131
132#[derive(Default)]
133pub(crate) struct DiagnosticCollector {
134    diagnostics: Vec<ConfigurationDiagnostic>,
135}
136
137impl DiagnosticCollector {
138    pub(crate) fn push(&mut self, diagnostic: ConfigurationDiagnostic) {
139        self.diagnostics.push(diagnostic);
140    }
141
142    pub(crate) fn into_result(self) -> Result<(), ConfigurationErrors> {
143        if self.diagnostics.is_empty() {
144            Ok(())
145        } else {
146            Err(ConfigurationErrors::from_diagnostics(self.diagnostics))
147        }
148    }
149}
150
151pub(crate) fn single_error(diagnostic: ConfigurationDiagnostic) -> ConfigurationErrors {
152    ConfigurationErrors::from_diagnostics(vec![diagnostic])
153}
154
155pub(crate) fn toml_location(source: &str, error: &toml::de::Error) -> Option<(usize, usize)> {
156    let offset = error.span()?.start.min(source.len());
157    let prefix = source.get(..offset)?;
158    let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1;
159    let column = prefix
160        .rsplit_once('\n')
161        .map_or(prefix, |(_, suffix)| suffix)
162        .chars()
163        .count()
164        + 1;
165    Some((line, column))
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn stable_codes_match_the_serde_contract() {
174        let cases = [
175            (
176                ConfigurationValidationCode::WorkingDirectoryUnavailable,
177                "working_directory_unavailable",
178            ),
179            (
180                ConfigurationValidationCode::HostFileUnreadable,
181                "host_file_unreadable",
182            ),
183            (
184                ConfigurationValidationCode::HostDocumentTooLarge,
185                "host_document_too_large",
186            ),
187            (
188                ConfigurationValidationCode::HostTextInvalidUtf8,
189                "host_text_invalid_utf8",
190            ),
191            (
192                ConfigurationValidationCode::HostTomlInvalid,
193                "host_toml_invalid",
194            ),
195            (
196                ConfigurationValidationCode::AdminBindInvalid,
197                "admin_bind_invalid",
198            ),
199            (
200                ConfigurationValidationCode::AdminOriginInvalid,
201                "admin_origin_invalid",
202            ),
203            (
204                ConfigurationValidationCode::SourceModeConflict,
205                "source_mode_conflict",
206            ),
207            (ConfigurationValidationCode::PathInvalid, "path_invalid"),
208            (
209                ConfigurationValidationCode::SecretReferenceInvalid,
210                "secret_reference_invalid",
211            ),
212            (
213                ConfigurationValidationCode::LimitOutOfRange,
214                "limit_out_of_range",
215            ),
216            (
217                ConfigurationValidationCode::DurationInvalid,
218                "duration_invalid",
219            ),
220            (
221                ConfigurationValidationCode::PageSizeInvalid,
222                "page_size_invalid",
223            ),
224            (
225                ConfigurationValidationCode::ContentLimitRelationshipInvalid,
226                "content_limit_relationship_invalid",
227            ),
228        ];
229
230        for (code, expected) in cases {
231            assert_eq!(code.as_str(), expected);
232            assert_eq!(serde_json::to_value(code).unwrap(), expected);
233        }
234    }
235
236    #[test]
237    fn diagnostics_are_sorted_and_do_not_contain_source_text() {
238        let errors = ConfigurationErrors::from_diagnostics(vec![
239            ConfigurationDiagnostic::new(
240                "paths.content_root",
241                ConfigurationValidationCode::PathInvalid,
242                "configured path must not be empty",
243            ),
244            ConfigurationDiagnostic::new(
245                "$document",
246                ConfigurationValidationCode::HostTomlInvalid,
247                "host TOML does not match the schema",
248            )
249            .at(2, 3),
250        ]);
251
252        assert_eq!(errors.diagnostics()[0].field.as_ref(), "$document");
253        let rendered = format!("{errors:?}");
254        assert!(!rendered.contains("credential-value"));
255        assert!(!rendered.contains("source ="));
256    }
257
258    #[test]
259    fn diagnostic_wire_contract_keeps_the_host_authority() {
260        let diagnostic = ConfigurationDiagnostic::new(
261            "$document",
262            ConfigurationValidationCode::HostTomlInvalid,
263            "host TOML does not match the schema",
264        )
265        .at(2, 3);
266
267        assert_eq!(
268            serde_json::to_value(diagnostic).unwrap(),
269            serde_json::from_str::<serde_json::Value>(
270                r#"{"authority":"host","field":"$document","code":"host_toml_invalid","message":"host TOML does not match the schema","line":2,"column":3}"#,
271            )
272            .unwrap()
273        );
274    }
275
276    #[test]
277    fn toml_locations_use_one_based_unicode_scalar_columns() {
278        #[derive(Debug, Deserialize)]
279        struct Document {
280            #[serde(rename = "root")]
281            _root: Root,
282        }
283
284        #[derive(Debug, Deserialize)]
285        struct Root {
286            #[serde(rename = "label")]
287            _label: String,
288            #[serde(rename = "count")]
289            _count: u64,
290        }
291
292        let source = "root = { label = \"é\", count = \"bad\" }\n";
293        let error = toml::from_str::<Document>(source).unwrap_err();
294
295        assert_eq!(toml_location(source, &error), Some((1, 31)));
296    }
297}