cleanlib_client/
risk_acceptance.rs1use 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 {
78 if s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | '@')) && !s.is_empty() {
79 s.to_string()
80 } else {
81 format!("\"{}\"", yaml_escape(s))
82 }
83}
84
85fn yaml_escape(s: &str) -> String {
90 let mut out = String::with_capacity(s.len());
91 for c in s.chars() {
92 match c {
93 '\\' => out.push_str("\\\\"),
94 '"' => out.push_str("\\\""),
95 '\n' => out.push_str("\\n"),
96 '\r' => out.push_str("\\r"),
97 '\t' => out.push_str("\\t"),
98 c => out.push(c),
99 }
100 }
101 out
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use chrono::TimeZone;
108
109 fn fixed_ts() -> DateTime<Utc> {
110 Utc.with_ymd_and_hms(2026, 5, 21, 10, 0, 0).unwrap()
111 }
112
113 #[test]
114 fn emit_minimal_rule_no_proposed_by() {
115 let rule = Rule {
116 ecosystem: "maven".to_string(),
117 package: "log4j-core".to_string(),
118 version_range: "<2.15".to_string(),
119 justification: "Legacy system; vendor patch pending Q3 2026".to_string(),
120 proposed_by: None,
121 proposed_at: fixed_ts(),
122 };
123 let out = emit_yaml(&rule);
124 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";
125 assert_eq!(out, expected);
126 }
127
128 #[test]
129 fn emit_full_rule_with_proposed_by() {
130 let rule = Rule {
131 ecosystem: "maven".to_string(),
132 package: "log4j-core".to_string(),
133 version_range: "<2.15".to_string(),
134 justification: "Legacy".to_string(),
135 proposed_by: Some("admin@company.com".to_string()),
136 proposed_at: fixed_ts(),
137 };
138 let out = emit_yaml(&rule);
139 assert!(out.contains("proposed_by: \"admin@company.com\""));
140 }
141
142 #[test]
143 fn justification_with_quotes_escaped() {
144 let rule = Rule {
145 ecosystem: "npm".to_string(),
146 package: "left-pad".to_string(),
147 version_range: "0.0.3".to_string(),
148 justification: r#"customer said "we need this""#.to_string(),
149 proposed_by: None,
150 proposed_at: fixed_ts(),
151 };
152 let out = emit_yaml(&rule);
153 assert!(out.contains(r#"justification: "customer said \"we need this\"""#));
154 }
155
156 #[test]
157 fn justification_with_backslash_escaped() {
158 let rule = Rule {
159 ecosystem: "npm".to_string(),
160 package: "lodash".to_string(),
161 version_range: "1.0".to_string(),
162 justification: r"path\to\thing".to_string(),
163 proposed_by: None,
164 proposed_at: fixed_ts(),
165 };
166 let out = emit_yaml(&rule);
167 assert!(out.contains(r#"justification: "path\\to\\thing""#));
168 }
169
170 #[test]
171 fn justification_with_newline_escaped() {
172 let rule = Rule {
173 ecosystem: "npm".to_string(),
174 package: "x".to_string(),
175 version_range: "*".to_string(),
176 justification: "line1\nline2".to_string(),
177 proposed_by: None,
178 proposed_at: fixed_ts(),
179 };
180 let out = emit_yaml(&rule);
181 assert!(out.contains(r#"justification: "line1\nline2""#));
182 }
183
184 #[test]
185 fn package_with_npm_scope_quoted() {
186 let rule = Rule {
189 ecosystem: "npm".to_string(),
190 package: "@my-org/foo".to_string(),
191 version_range: "^1.0".to_string(),
192 justification: "x".to_string(),
193 proposed_by: None,
194 proposed_at: fixed_ts(),
195 };
196 let out = emit_yaml(&rule);
197 assert!(out.contains("package: @my-org/foo\n"));
198 }
199
200 #[test]
201 fn package_with_space_gets_quoted() {
202 let rule = Rule {
203 ecosystem: "npm".to_string(),
204 package: "weird package".to_string(),
205 version_range: "1".to_string(),
206 justification: "x".to_string(),
207 proposed_by: None,
208 proposed_at: fixed_ts(),
209 };
210 let out = emit_yaml(&rule);
211 assert!(out.contains("package: \"weird package\"\n"));
212 }
213
214 #[test]
219 fn ecosystem_field_is_emitted_first() {
220 let rule = Rule {
221 ecosystem: "pypi".to_string(),
222 package: "requests".to_string(),
223 version_range: "2.31.0".to_string(),
224 justification: "vendor pin".to_string(),
225 proposed_by: None,
226 proposed_at: fixed_ts(),
227 };
228 let out = emit_yaml(&rule);
229 let eco_pos = out.find("ecosystem: pypi").expect("ecosystem field present");
233 let pkg_pos = out.find("package: requests").expect("package field present");
234 assert!(eco_pos < pkg_pos, "ecosystem must precede package in YAML");
235 }
236}