1use chrono::{DateTime, Utc};
32
33pub struct Rule {
36 pub ecosystem: String,
40 pub package: String,
41 pub version_range: String,
42 pub justification: String,
43 pub proposed_by: Option<String>,
47 pub proposed_at: DateTime<Utc>,
48}
49
50pub fn emit_yaml(rule: &Rule) -> String {
52 let mut out = String::new();
53 out.push_str("risk_accepted:\n");
54 out.push_str(&format!(" - ecosystem: {}\n", yaml_inline(&rule.ecosystem)));
58 out.push_str(&format!(" package: {}\n", yaml_inline(&rule.package)));
59 out.push_str(&format!(" version_range: \"{}\"\n", yaml_escape(&rule.version_range)));
60 out.push_str(&format!(
61 " justification: \"{}\"\n",
62 yaml_escape(&rule.justification)
63 ));
64 if let Some(by) = &rule.proposed_by {
65 out.push_str(&format!(" proposed_by: \"{}\"\n", yaml_escape(by)));
66 }
67 out.push_str(&format!(
68 " proposed_at: \"{}\"\n",
69 rule.proposed_at.to_rfc3339()
70 ));
71 out
72}
73
74fn yaml_inline(s: &str) -> String {
84 let plain_safe = !s.is_empty()
85 && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | '@'))
86 && !looks_like_non_string_scalar(s);
87 if plain_safe {
88 s.to_string()
89 } else {
90 format!("\"{}\"", yaml_escape(s))
91 }
92}
93
94fn looks_like_non_string_scalar(s: &str) -> bool {
100 if s.is_empty() {
101 return true;
102 }
103 if matches!(
104 s.to_ascii_lowercase().as_str(),
105 "true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~" | "y" | "n"
106 ) {
107 return true;
108 }
109 s.parse::<i64>().is_ok() || s.parse::<f64>().is_ok()
110}
111
112fn yaml_escape(s: &str) -> String {
117 let mut out = String::with_capacity(s.len());
118 for c in s.chars() {
119 match c {
120 '\\' => out.push_str("\\\\"),
121 '"' => out.push_str("\\\""),
122 '\n' => out.push_str("\\n"),
123 '\r' => out.push_str("\\r"),
124 '\t' => out.push_str("\\t"),
125 c => out.push(c),
126 }
127 }
128 out
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use chrono::TimeZone;
135
136 fn fixed_ts() -> DateTime<Utc> {
137 Utc.with_ymd_and_hms(2026, 5, 21, 10, 0, 0).unwrap()
138 }
139
140 #[test]
141 fn emit_minimal_rule_no_proposed_by() {
142 let rule = Rule {
143 ecosystem: "maven".to_string(),
144 package: "log4j-core".to_string(),
145 version_range: "<2.15".to_string(),
146 justification: "Legacy system; vendor patch pending Q3 2026".to_string(),
147 proposed_by: None,
148 proposed_at: fixed_ts(),
149 };
150 let out = emit_yaml(&rule);
151 let expected = "risk_accepted:\n - ecosystem: maven\n package: log4j-core\n version_range: \"<2.15\"\n justification: \"Legacy system; vendor patch pending Q3 2026\"\n proposed_at: \"2026-05-21T10:00:00+00:00\"\n";
152 assert_eq!(out, expected);
153 }
154
155 #[test]
156 fn emit_full_rule_with_proposed_by() {
157 let rule = Rule {
158 ecosystem: "maven".to_string(),
159 package: "log4j-core".to_string(),
160 version_range: "<2.15".to_string(),
161 justification: "Legacy".to_string(),
162 proposed_by: Some("admin@company.com".to_string()),
163 proposed_at: fixed_ts(),
164 };
165 let out = emit_yaml(&rule);
166 assert!(out.contains("proposed_by: \"admin@company.com\""));
167 }
168
169 #[test]
170 fn justification_with_quotes_escaped() {
171 let rule = Rule {
172 ecosystem: "npm".to_string(),
173 package: "left-pad".to_string(),
174 version_range: "0.0.3".to_string(),
175 justification: r#"customer said "we need this""#.to_string(),
176 proposed_by: None,
177 proposed_at: fixed_ts(),
178 };
179 let out = emit_yaml(&rule);
180 assert!(out.contains(r#"justification: "customer said \"we need this\"""#));
181 }
182
183 #[test]
184 fn justification_with_backslash_escaped() {
185 let rule = Rule {
186 ecosystem: "npm".to_string(),
187 package: "lodash".to_string(),
188 version_range: "1.0".to_string(),
189 justification: r"path\to\thing".to_string(),
190 proposed_by: None,
191 proposed_at: fixed_ts(),
192 };
193 let out = emit_yaml(&rule);
194 assert!(out.contains(r#"justification: "path\\to\\thing""#));
195 }
196
197 #[test]
198 fn justification_with_newline_escaped() {
199 let rule = Rule {
200 ecosystem: "npm".to_string(),
201 package: "x".to_string(),
202 version_range: "*".to_string(),
203 justification: "line1\nline2".to_string(),
204 proposed_by: None,
205 proposed_at: fixed_ts(),
206 };
207 let out = emit_yaml(&rule);
208 assert!(out.contains(r#"justification: "line1\nline2""#));
209 }
210
211 #[test]
212 fn package_with_npm_scope_quoted() {
213 let rule = Rule {
216 ecosystem: "npm".to_string(),
217 package: "@my-org/foo".to_string(),
218 version_range: "^1.0".to_string(),
219 justification: "x".to_string(),
220 proposed_by: None,
221 proposed_at: fixed_ts(),
222 };
223 let out = emit_yaml(&rule);
224 assert!(out.contains("package: @my-org/foo\n"));
225 }
226
227 #[test]
228 fn package_with_space_gets_quoted() {
229 let rule = Rule {
230 ecosystem: "npm".to_string(),
231 package: "weird package".to_string(),
232 version_range: "1".to_string(),
233 justification: "x".to_string(),
234 proposed_by: None,
235 proposed_at: fixed_ts(),
236 };
237 let out = emit_yaml(&rule);
238 assert!(out.contains("package: \"weird package\"\n"));
239 }
240
241 #[test]
246 fn ecosystem_field_is_emitted_first() {
247 let rule = Rule {
248 ecosystem: "pypi".to_string(),
249 package: "requests".to_string(),
250 version_range: "2.31.0".to_string(),
251 justification: "vendor pin".to_string(),
252 proposed_by: None,
253 proposed_at: fixed_ts(),
254 };
255 let out = emit_yaml(&rule);
256 let eco_pos = out.find("ecosystem: pypi").expect("ecosystem field present");
260 let pkg_pos = out.find("package: requests").expect("package field present");
261 assert!(eco_pos < pkg_pos, "ecosystem must precede package in YAML");
262 }
263
264 #[test]
274 fn cleanlib_784_numeric_package_is_quoted() {
275 let rule = Rule {
276 ecosystem: "npm".to_string(),
277 package: "15764".to_string(),
278 version_range: "2.8.6".to_string(),
279 justification: "approve".to_string(),
280 proposed_by: None,
281 proposed_at: fixed_ts(),
282 };
283 let out = emit_yaml(&rule);
284 assert!(
285 out.contains("package: \"15764\"\n"),
286 "numeric package must stay a quoted string, got: {out}"
287 );
288 }
289
290 #[test]
291 fn cleanlib_784_numeric_ecosystem_is_quoted() {
292 let rule = Rule {
293 ecosystem: "15764".to_string(),
294 package: "cors".to_string(),
295 version_range: "2.8.6".to_string(),
296 justification: "approve".to_string(),
297 proposed_by: None,
298 proposed_at: fixed_ts(),
299 };
300 let out = emit_yaml(&rule);
301 assert!(
302 out.contains("ecosystem: \"15764\"\n"),
303 "numeric ecosystem must stay a quoted string, got: {out}"
304 );
305 }
306
307 #[test]
308 fn cleanlib_784_float_and_scientific_notation_are_quoted() {
309 for value in ["0.1", "1e5"] {
310 let rule = Rule {
311 ecosystem: "npm".to_string(),
312 package: value.to_string(),
313 version_range: "1.0".to_string(),
314 justification: "x".to_string(),
315 proposed_by: None,
316 proposed_at: fixed_ts(),
317 };
318 let out = emit_yaml(&rule);
319 assert!(
320 out.contains(&format!("package: \"{value}\"\n")),
321 "float-shaped value {value} must stay quoted, got: {out}"
322 );
323 }
324 }
325
326 #[test]
327 fn cleanlib_784_yaml11_reserved_words_are_quoted() {
328 for value in ["true", "false", "null", "~", "no", "on", "off", "yes", "TRUE", "Off"] {
329 let rule = Rule {
330 ecosystem: "npm".to_string(),
331 package: value.to_string(),
332 version_range: "1.0".to_string(),
333 justification: "x".to_string(),
334 proposed_by: None,
335 proposed_at: fixed_ts(),
336 };
337 let out = emit_yaml(&rule);
338 assert!(
339 out.contains(&format!("package: \"{value}\"\n")),
340 "YAML 1.1 reserved word {value} must stay quoted, got: {out}"
341 );
342 }
343 }
344
345 #[test]
346 fn cleanlib_784_ordinary_identifiers_stay_unquoted() {
347 for value in ["log4j-core", "left-pad", "npm", "pypi", "my.package", "@scope/pkg"] {
351 let rule = Rule {
352 ecosystem: "npm".to_string(),
353 package: value.to_string(),
354 version_range: "1.0".to_string(),
355 justification: "x".to_string(),
356 proposed_by: None,
357 proposed_at: fixed_ts(),
358 };
359 let out = emit_yaml(&rule);
360 assert!(
361 out.contains(&format!("package: {value}\n")),
362 "ordinary identifier {value} must stay unquoted (no regression), got: {out}"
363 );
364 }
365 }
366}