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, OR if the string is itself a plain scalar that
77/// a YAML 1.1 parser would coerce to a non-string type (CLEANLIB-784):
78/// `is_ascii_alphanumeric()` is true for digits and for bare words like
79/// `on`/`off`/`yes`/`no`, so a purely-numeric or reserved-word value (e.g.
80/// an `--ecosystem`/`--package` argument of `15764` or `on`) previously
81/// came out unquoted as `field: 15764` / `field: on` — a YAML integer or
82/// boolean, not the string the CLI meant to record.
83fn 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
94/// True if `s`, left unquoted, would parse as a YAML 1.1 boolean/null/
95/// numeric scalar instead of staying a string. Covers the exact risk list
96/// from CLEANLIB-784 (`true`, `false`, `null`, `~`, `no`, `on`, `off`,
97/// `0.1`, `1e5`) plus any other integer/float-shaped value via the same
98/// parse cleanlib-cli itself would use to detect the coercion.
99fn 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
112/// Escape characters that would break a double-quoted YAML string.
113/// Per YAML 1.2 §5.7 double-quoted scalar escape rules — backslash and
114/// double-quote are the load-bearing escapes; control chars are rare in
115/// our inputs but covered defensively.
116fn 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        // npm scoped packages like @my-org/foo have / and @ which ARE in
214        // the inline-form allowed set, so they stay unquoted.
215        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    /// CLEANLIB-80 regression guard: the emitted YAML MUST carry an
242    /// `ecosystem` field so an uploaded rule can be disambiguated when
243    /// the same package name exists in multiple ecosystems (e.g.
244    /// `requests` in both PyPI and RubyGems).
245    #[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        // Ecosystem is emitted before package — reviewers see the
257        // disambiguating field before the potentially-cross-ecosystem
258        // package name.
259        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    // ─── CLEANLIB-784 · yaml_inline must quote non-string-looking plain
265    // scalars ──────────────────────────────────────────────────────────
266    //
267    // `is_ascii_alphanumeric()` is true for digits and for bare words like
268    // `on`/`off`, so a numeric or YAML-1.1-reserved-word `package`/
269    // `ecosystem` value previously came out unquoted — a YAML integer or
270    // boolean, not the string the CLI recorded. Live repro (pre-fix, on
271    // cleanlib-cli 0.1.14): `--package 15764` emitted `package: 15764`.
272
273    #[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        // Regression guard the other direction: the fix must not start
348        // over-quoting every plain identifier (log4j-core, left-pad, npm,
349        // pypi, etc.) — only the values that are actually ambiguous.
350        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}