1use super::types::FunctionSpec;
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8use thiserror::Error;
9
10pub const CURRENT_VERSION: &str = "1.0";
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct SpecFile {
16 pub version: String,
18
19 #[serde(default)]
21 pub specs: Vec<FunctionSpec>,
22}
23
24impl SpecFile {
25 #[must_use]
27 pub fn new() -> Self {
28 Self {
29 version: CURRENT_VERSION.to_string(),
30 specs: Vec::new(),
31 }
32 }
33
34 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 if file.version != CURRENT_VERSION {
44 return Err(SchemaError::UnsupportedVersion {
45 found: file.version,
46 expected: CURRENT_VERSION.to_string(),
47 });
48 }
49
50 for (i, spec) in file.specs.iter().enumerate() {
52 validate_spec(spec, i)?;
53 }
54
55 Ok(file)
56 }
57
58 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
84fn validate_spec(spec: &FunctionSpec, index: usize) -> Result<(), SchemaError> {
86 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 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 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 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
130fn 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#[derive(Debug, Error)]
151pub enum SchemaError {
152 #[error("YAML parse error: {0}")]
154 Yaml(String),
155
156 #[error("cannot read '{path}': {message}")]
158 Io {
159 path: String,
161 message: String,
163 },
164
165 #[error("error in '{path}': {message}")]
167 ParseError {
168 path: String,
170 message: String,
172 },
173
174 #[error("unsupported version '{found}', expected '{expected}'")]
176 UnsupportedVersion {
177 found: String,
179 expected: String,
181 },
182
183 #[error("{field}: {message}")]
185 Validation {
186 field: String,
188 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}