Skip to main content

cleanlib_client/
risk_acceptance.rs

1//! Risk-acceptance rule YAML emit per Client spec rev1 §11.1 +
2//! phase-1-storage-architecture §3 customer-flow.
3//!
4//! Customer runs `cleanlib risk-accept` → CLI emits a YAML rule the
5//! customer uploads to CDP admin UI to permit serve of a high-risk package
6//! they need for legacy / vendor-pinned reasons.
7//!
8//! Output shape:
9//! ```yaml
10//! risk_accepted:
11//!   - package: log4j-core
12//!     version_range: "<2.15"
13//!     justification: "Legacy system; vendor patch pending Q3 2026"
14//!     proposed_by: "customer-admin@company.com"
15//!     proposed_at: "2026-05-21T10:00:00Z"
16//! ```
17//!
18//! Phase 1 hand-formats YAML rather than depending on `serde_yaml` (which
19//! is deprecated upstream and would add ~30 transitive deps). The shape is
20//! small + predictable; `yaml_escape` handles quote + backslash characters
21//! in the justification (the only user-controlled string field).
22
23use chrono::{DateTime, Utc};
24
25/// Risk-acceptance rule for one package + version-range.
26pub struct Rule {
27    pub package: String,
28    pub version_range: String,
29    pub justification: String,
30    /// Customer who's proposing the rule. Typically resolved from CDP user
31    /// identity on the auth path; CLI accepts an explicit `--proposed-by`
32    /// flag when the run-time auth context can't resolve it.
33    pub proposed_by: Option<String>,
34    pub proposed_at: DateTime<Utc>,
35}
36
37/// Emit the rule as a YAML document suitable for upload to CDP admin UI.
38pub 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
57/// Inline-form for simple alphanumeric/dash/underscore strings (no quoting).
58/// Falls back to double-quoted form if the string contains characters that
59/// would need YAML escaping.
60fn 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
68/// Escape characters that would break a double-quoted YAML string.
69/// Per YAML 1.2 §5.7 double-quoted scalar escape rules — backslash and
70/// double-quote are the load-bearing escapes; control chars are rare in
71/// our inputs but covered defensively.
72fn 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        // npm scoped packages like @my-org/foo have / and @ which ARE in
165        // the inline-form allowed set, so they stay unquoted.
166        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}