Skip to main content

saf_core/spec/
schema.rs

1//! YAML schema parsing and validation for function specifications.
2//!
3//! Handles loading spec files with version validation and actionable error messages.
4
5use super::types::FunctionSpec;
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8use thiserror::Error;
9
10/// The current spec file format version.
11pub const CURRENT_VERSION: &str = "1.0";
12
13/// A spec file containing multiple function specifications.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct SpecFile {
16    /// Schema version (must be "1.0").
17    pub version: String,
18
19    /// List of function specifications.
20    #[serde(default)]
21    pub specs: Vec<FunctionSpec>,
22}
23
24impl SpecFile {
25    /// Create a new empty spec file with current version.
26    #[must_use]
27    pub fn new() -> Self {
28        Self {
29            version: CURRENT_VERSION.to_string(),
30            specs: Vec::new(),
31        }
32    }
33
34    /// Parse a spec file from YAML string.
35    ///
36    /// # Errors
37    /// Returns an error if YAML parsing fails or version is unsupported.
38    pub fn parse(content: &str) -> Result<Self, SchemaError> {
39        let file: Self =
40            serde_yaml::from_str(content).map_err(|e| SchemaError::Yaml(e.to_string()))?;
41
42        // Validate version
43        if file.version != CURRENT_VERSION {
44            return Err(SchemaError::UnsupportedVersion {
45                found: file.version,
46                expected: CURRENT_VERSION.to_string(),
47            });
48        }
49
50        // Validate each spec
51        for (i, spec) in file.specs.iter().enumerate() {
52            validate_spec(spec, i)?;
53        }
54
55        Ok(file)
56    }
57
58    /// Load a spec file from a path.
59    ///
60    /// # Errors
61    /// Returns an error if file cannot be read or parsed.
62    pub fn load(path: &Path) -> Result<Self, SchemaError> {
63        let content = std::fs::read_to_string(path).map_err(|e| SchemaError::Io {
64            path: path.display().to_string(),
65            message: e.to_string(),
66        })?;
67
68        Self::parse(&content).map_err(|e| match e {
69            SchemaError::Yaml(msg) => SchemaError::ParseError {
70                path: path.display().to_string(),
71                message: msg,
72            },
73            other => other,
74        })
75    }
76}
77
78impl Default for SpecFile {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84/// Validate a function specification.
85fn validate_spec(spec: &FunctionSpec, index: usize) -> Result<(), SchemaError> {
86    // Name is required and must not be empty
87    if spec.name.is_empty() {
88        return Err(SchemaError::Validation {
89            field: format!("specs[{index}].name"),
90            message: "function name cannot be empty".to_string(),
91        });
92    }
93
94    // Validate param indices are unique
95    let mut seen_indices = std::collections::BTreeSet::new();
96    for param in &spec.params {
97        if !seen_indices.insert(param.index) {
98            return Err(SchemaError::Validation {
99                field: format!("specs[{index}].params"),
100                message: format!("duplicate parameter index {}", param.index),
101            });
102        }
103    }
104
105    // Validate alias references
106    if let Some(returns) = &spec.returns {
107        if let Some(alias) = &returns.aliases {
108            if !alias.starts_with("param.") {
109                return Err(SchemaError::Validation {
110                    field: format!("specs[{index}].returns.aliases"),
111                    message: format!("invalid alias format '{alias}', expected 'param.N'"),
112                });
113            }
114        }
115    }
116
117    // Validate taint propagation references
118    if let Some(taint) = &spec.taint {
119        for (j, prop) in taint.propagates.iter().enumerate() {
120            validate_taint_location(prop.from, index, j, "from")?;
121            for to in &prop.to {
122                validate_taint_location(*to, index, j, "to")?;
123            }
124        }
125    }
126
127    Ok(())
128}
129
130/// Validate a [`TaintLocation`] is not `Unknown`.
131///
132/// Returns an error if the location is [`TaintLocation::Unknown`], which indicates
133/// an unrecognized string was used in the YAML spec.
134fn validate_taint_location(
135    location: super::TaintLocation,
136    spec_idx: usize,
137    prop_idx: usize,
138    field: &str,
139) -> Result<(), SchemaError> {
140    if matches!(location, super::TaintLocation::Unknown) {
141        return Err(SchemaError::Validation {
142            field: format!("specs[{spec_idx}].taint.propagates[{prop_idx}].{field}"),
143            message: "invalid taint location, expected 'param.N' or 'return'".to_string(),
144        });
145    }
146    Ok(())
147}
148
149/// Errors that can occur when parsing spec files.
150#[derive(Debug, Error)]
151pub enum SchemaError {
152    /// YAML parsing error.
153    #[error("YAML parse error: {0}")]
154    Yaml(String),
155
156    /// File I/O error.
157    #[error("cannot read '{path}': {message}")]
158    Io {
159        /// Path that failed to read.
160        path: String,
161        /// I/O error message.
162        message: String,
163    },
164
165    /// Parse error with file context.
166    #[error("error in '{path}': {message}")]
167    ParseError {
168        /// Path of the file.
169        path: String,
170        /// Parse error message.
171        message: String,
172    },
173
174    /// Unsupported spec version.
175    #[error("unsupported version '{found}', expected '{expected}'")]
176    UnsupportedVersion {
177        /// Version found in file.
178        found: String,
179        /// Expected version.
180        expected: String,
181    },
182
183    /// Validation error.
184    #[error("{field}: {message}")]
185    Validation {
186        /// Field that failed validation.
187        field: String,
188        /// Validation error message.
189        message: String,
190    },
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test_parse_minimal_spec() {
199        let yaml = r#"
200version: "1.0"
201specs:
202  - name: malloc
203    role: allocator
204"#;
205        let file = SpecFile::parse(yaml).unwrap();
206        assert_eq!(file.version, "1.0");
207        assert_eq!(file.specs.len(), 1);
208        assert_eq!(file.specs[0].name, "malloc");
209    }
210
211    #[test]
212    fn test_parse_full_spec() {
213        let yaml = r#"
214version: "1.0"
215specs:
216  - name: strcpy
217    params:
218      - index: 0
219        name: dst
220        modifies: true
221        nullness: required_nonnull
222      - index: 1
223        name: src
224        reads: true
225        nullness: required_nonnull
226    returns:
227      aliases: param.0
228      nullness: not_null
229    taint:
230      propagates:
231        - from: param.1
232          to: [param.0, return]
233"#;
234        let file = SpecFile::parse(yaml).unwrap();
235        assert_eq!(file.specs.len(), 1);
236        let spec = &file.specs[0];
237        assert_eq!(spec.name, "strcpy");
238        assert_eq!(spec.params.len(), 2);
239        assert!(spec.params[0].modifies.unwrap());
240        assert_eq!(
241            spec.returns.as_ref().unwrap().aliases,
242            Some("param.0".to_string())
243        );
244    }
245
246    #[test]
247    fn test_parse_empty_specs() {
248        let yaml = r#"
249version: "1.0"
250specs: []
251"#;
252        let file = SpecFile::parse(yaml).unwrap();
253        assert!(file.specs.is_empty());
254    }
255
256    #[test]
257    fn test_unsupported_version() {
258        let yaml = r#"
259version: "2.0"
260specs: []
261"#;
262        let err = SpecFile::parse(yaml).unwrap_err();
263        assert!(matches!(err, SchemaError::UnsupportedVersion { .. }));
264    }
265
266    #[test]
267    fn test_empty_name_error() {
268        let yaml = r#"
269version: "1.0"
270specs:
271  - name: ""
272"#;
273        let err = SpecFile::parse(yaml).unwrap_err();
274        assert!(matches!(err, SchemaError::Validation { .. }));
275        assert!(err.to_string().contains("cannot be empty"));
276    }
277
278    #[test]
279    fn test_duplicate_param_index_error() {
280        let yaml = r#"
281version: "1.0"
282specs:
283  - name: foo
284    params:
285      - index: 0
286      - index: 0
287"#;
288        let err = SpecFile::parse(yaml).unwrap_err();
289        assert!(matches!(err, SchemaError::Validation { .. }));
290        assert!(err.to_string().contains("duplicate parameter index"));
291    }
292
293    #[test]
294    fn test_invalid_alias_error() {
295        let yaml = r#"
296version: "1.0"
297specs:
298  - name: foo
299    returns:
300      aliases: invalid
301"#;
302        let err = SpecFile::parse(yaml).unwrap_err();
303        assert!(matches!(err, SchemaError::Validation { .. }));
304        assert!(err.to_string().contains("invalid alias format"));
305    }
306
307    #[test]
308    fn test_invalid_taint_ref_error() {
309        let yaml = r#"
310version: "1.0"
311specs:
312  - name: foo
313    taint:
314      propagates:
315        - from: invalid
316          to: [return]
317"#;
318        let err = SpecFile::parse(yaml).unwrap_err();
319        assert!(matches!(err, SchemaError::Validation { .. }));
320        assert!(err.to_string().contains("invalid taint location"));
321    }
322
323    #[test]
324    fn test_yaml_parse_error() {
325        let yaml = "this is not: valid: yaml: :";
326        let err = SpecFile::parse(yaml).unwrap_err();
327        assert!(matches!(err, SchemaError::Yaml(_)));
328    }
329}