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//!   - ecosystem: npm
12//!     package: log4j-core
13//!     version_range: "<2.15"
14//!     justification: "Legacy system; vendor patch pending Q3 2026"
15//!     proposed_by: "customer-admin@company.com"
16//!     proposed_at: "2026-05-21T10:00:00Z"
17//! ```
18//!
19//! Phase 1 hand-formats YAML rather than depending on `serde_yaml` (which
20//! is deprecated upstream and would add ~30 transitive deps). The shape is
21//! small + predictable; `yaml_escape` handles quote + backslash characters
22//! in the justification (the only user-controlled string field).
23//!
24//! CLEANLIB-80: `ecosystem` field is emitted first so uploaded rules can be
25//! disambiguated across ecosystems (a `requests` package exists in both
26//! PyPI and RubyGems, `lodash` only in npm, etc.). Every sibling CLI
27//! command (`verdict`, `scan`, `policy preview`, `audit`) already carries
28//! an ecosystem argument — the rule YAML now matches that shape so the
29//! policy engine can match unambiguously.
30
31use chrono::{DateTime, Utc};
32
33/// Risk-acceptance rule for one package + version-range in a given
34/// ecosystem.
35pub struct Rule {
36    /// Package ecosystem — matches the ecosystem string used by the rest
37    /// of the CLI (`npm`, `pypi`, `cargo`, `go`, `maven`, `nuget`,
38    /// `rubygems`, `composer`). CLEANLIB-80.
39    pub ecosystem: String,
40    pub package: String,
41    pub version_range: String,
42    pub justification: String,
43    /// Customer who's proposing the rule. Typically resolved from CDP user
44    /// identity on the auth path; CLI accepts an explicit `--proposed-by`
45    /// flag when the run-time auth context can't resolve it.
46    pub proposed_by: Option<String>,
47    pub proposed_at: DateTime<Utc>,
48}
49
50/// Emit the rule as a YAML document suitable for upload to CDP admin UI.
51pub fn emit_yaml(rule: &Rule) -> String {
52    let mut out = String::new();
53    out.push_str("risk_accepted:\n");
54    // CLEANLIB-80: ecosystem is emitted first (before `package`) so
55    // customers reviewing rule YAML at a glance see the disambiguating
56    // field before the potentially-cross-ecosystem package name.
57    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
74/// Inline-form for simple alphanumeric/dash/underscore strings (no quoting).
75/// Falls back to double-quoted form if the string contains characters that
76/// would need YAML escaping.
77fn 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
85/// Escape characters that would break a double-quoted YAML string.
86/// Per YAML 1.2 §5.7 double-quoted scalar escape rules — backslash and
87/// double-quote are the load-bearing escapes; control chars are rare in
88/// our inputs but covered defensively.
89fn 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        // npm scoped packages like @my-org/foo have / and @ which ARE in
187        // the inline-form allowed set, so they stay unquoted.
188        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    /// CLEANLIB-80 regression guard: the emitted YAML MUST carry an
215    /// `ecosystem` field so an uploaded rule can be disambiguated when
216    /// the same package name exists in multiple ecosystems (e.g.
217    /// `requests` in both PyPI and RubyGems).
218    #[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        // Ecosystem is emitted before package — reviewers see the
230        // disambiguating field before the potentially-cross-ecosystem
231        // package name.
232        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}