cleanlib_client/
risk_acceptance.rs1use chrono::{DateTime, Utc};
24
25pub struct Rule {
27 pub package: String,
28 pub version_range: String,
29 pub justification: String,
30 pub proposed_by: Option<String>,
34 pub proposed_at: DateTime<Utc>,
35}
36
37pub fn emit_yaml(rule: &Rule) -> String {
39 let mut out = String::new();
40 out.push_str("risk_accepted:\n");
41 out.push_str(&format!(" - package: {}\n", yaml_inline(&rule.package)));
42 out.push_str(&format!(" version_range: \"{}\"\n", yaml_escape(&rule.version_range)));
43 out.push_str(&format!(
44 " justification: \"{}\"\n",
45 yaml_escape(&rule.justification)
46 ));
47 if let Some(by) = &rule.proposed_by {
48 out.push_str(&format!(" proposed_by: \"{}\"\n", yaml_escape(by)));
49 }
50 out.push_str(&format!(
51 " proposed_at: \"{}\"\n",
52 rule.proposed_at.to_rfc3339()
53 ));
54 out
55}
56
57fn yaml_inline(s: &str) -> String {
61 if s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | '@')) && !s.is_empty() {
62 s.to_string()
63 } else {
64 format!("\"{}\"", yaml_escape(s))
65 }
66}
67
68fn yaml_escape(s: &str) -> String {
73 let mut out = String::with_capacity(s.len());
74 for c in s.chars() {
75 match c {
76 '\\' => out.push_str("\\\\"),
77 '"' => out.push_str("\\\""),
78 '\n' => out.push_str("\\n"),
79 '\r' => out.push_str("\\r"),
80 '\t' => out.push_str("\\t"),
81 c => out.push(c),
82 }
83 }
84 out
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90 use chrono::TimeZone;
91
92 fn fixed_ts() -> DateTime<Utc> {
93 Utc.with_ymd_and_hms(2026, 5, 21, 10, 0, 0).unwrap()
94 }
95
96 #[test]
97 fn emit_minimal_rule_no_proposed_by() {
98 let rule = Rule {
99 package: "log4j-core".to_string(),
100 version_range: "<2.15".to_string(),
101 justification: "Legacy system; vendor patch pending Q3 2026".to_string(),
102 proposed_by: None,
103 proposed_at: fixed_ts(),
104 };
105 let out = emit_yaml(&rule);
106 let expected = "risk_accepted:\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";
107 assert_eq!(out, expected);
108 }
109
110 #[test]
111 fn emit_full_rule_with_proposed_by() {
112 let rule = Rule {
113 package: "log4j-core".to_string(),
114 version_range: "<2.15".to_string(),
115 justification: "Legacy".to_string(),
116 proposed_by: Some("admin@company.com".to_string()),
117 proposed_at: fixed_ts(),
118 };
119 let out = emit_yaml(&rule);
120 assert!(out.contains("proposed_by: \"admin@company.com\""));
121 }
122
123 #[test]
124 fn justification_with_quotes_escaped() {
125 let rule = Rule {
126 package: "left-pad".to_string(),
127 version_range: "0.0.3".to_string(),
128 justification: r#"customer said "we need this""#.to_string(),
129 proposed_by: None,
130 proposed_at: fixed_ts(),
131 };
132 let out = emit_yaml(&rule);
133 assert!(out.contains(r#"justification: "customer said \"we need this\"""#));
134 }
135
136 #[test]
137 fn justification_with_backslash_escaped() {
138 let rule = Rule {
139 package: "lodash".to_string(),
140 version_range: "1.0".to_string(),
141 justification: r"path\to\thing".to_string(),
142 proposed_by: None,
143 proposed_at: fixed_ts(),
144 };
145 let out = emit_yaml(&rule);
146 assert!(out.contains(r#"justification: "path\\to\\thing""#));
147 }
148
149 #[test]
150 fn justification_with_newline_escaped() {
151 let rule = Rule {
152 package: "x".to_string(),
153 version_range: "*".to_string(),
154 justification: "line1\nline2".to_string(),
155 proposed_by: None,
156 proposed_at: fixed_ts(),
157 };
158 let out = emit_yaml(&rule);
159 assert!(out.contains(r#"justification: "line1\nline2""#));
160 }
161
162 #[test]
163 fn package_with_npm_scope_quoted() {
164 let rule = Rule {
167 package: "@my-org/foo".to_string(),
168 version_range: "^1.0".to_string(),
169 justification: "x".to_string(),
170 proposed_by: None,
171 proposed_at: fixed_ts(),
172 };
173 let out = emit_yaml(&rule);
174 assert!(out.contains("package: @my-org/foo\n"));
175 }
176
177 #[test]
178 fn package_with_space_gets_quoted() {
179 let rule = Rule {
180 package: "weird package".to_string(),
181 version_range: "1".to_string(),
182 justification: "x".to_string(),
183 proposed_by: None,
184 proposed_at: fixed_ts(),
185 };
186 let out = emit_yaml(&rule);
187 assert!(out.contains("package: \"weird package\"\n"));
188 }
189}