csaf-crud 1.4.16

CSAF 2.0 / 2.1 advisory CRUD server with HATEOAS JSON API and HTML UI (TLS 1.3, HTTP/1.1 + HTTP/2 + HTTP/3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! The i18n test matrix, per `skills/languages-europe-rust` §7: round-trips,
//! menu-order invariants, the L×N coverage matrix, fallback semantics,
//! `parse_tsv` edge cases, the HTTP/2 split-cookie regression, and the
//! proptest layer.

#![allow(clippy::missing_panics_doc)]

use http::Request;
use http::header::COOKIE;
use proptest::prelude::*;

use super::*;

// ---------------------------------------------------------------------------
// Lang round-trips and menu-order invariants
// ---------------------------------------------------------------------------

#[test]
fn code_and_from_code_round_trip() {
    for lang in Lang::all() {
        assert_eq!(Lang::from_code(lang.code()), Some(lang));
    }
}

#[test]
fn from_code_is_case_insensitive_and_takes_primary_subtag() {
    assert_eq!(Lang::from_code("DE"), Some(Lang::De));
    assert_eq!(Lang::from_code("de-DE"), Some(Lang::De));
    assert_eq!(Lang::from_code("fr_CA"), Some(Lang::Fr));
    assert_eq!(Lang::from_code("uk-UA"), Some(Lang::Uk));
    assert_eq!(Lang::from_code("zz"), None);
    assert_eq!(Lang::from_code(""), None);
}

#[test]
fn iso_639_3_primary_subtags_parse() {
    // Montenegrin and Lule/Southern Sámi have no ISO 639-1 code — the
    // parser must not assume two letters.
    assert_eq!(Lang::from_code("cnr"), Some(Lang::Cnr));
    assert_eq!(Lang::from_code("smj"), Some(Lang::Smj));
    assert_eq!(Lang::from_code("sma-NO"), Some(Lang::Sma));
}

#[test]
fn the_nb_alias_maps_bokmal_browsers_to_norwegian() {
    // Bokmål-configured browsers send `Accept-Language: nb`, never `no`;
    // without this alias every Norwegian browser silently got English.
    assert_eq!(Lang::from_code("nb"), Some(Lang::No));
    assert_eq!(Lang::from_code("nb-NO"), Some(Lang::No));
    // The macrolanguage code stays the canonical one…
    assert_eq!(Lang::No.code(), "no");
    // …and Nynorsk is its own language, not an alias target.
    assert_eq!(Lang::from_code("nn"), Some(Lang::Nn));
}

#[test]
fn menu_has_49_languages_with_the_first_five_pinned() {
    let all = Lang::all();
    assert_eq!(all.len(), 49);
    // The historical front order is a UI contract — do not reorder.
    assert_eq!(
        &all[..5],
        &[Lang::En, Lang::De, Lang::Fr, Lang::Es, Lang::It]
    );
    // The alphabetical-by-English-name tail: first and last entries.
    // (Urdu sorts after Ukrainian, so it is last once Asia is included.)
    assert_eq!(all[5], Lang::Sq, "tail starts at Albanian");
    assert_eq!(all[48], Lang::Ur, "tail ends at Urdu");
}

#[test]
fn asian_additions_are_placed_by_english_name() {
    // Per skills/languages-asia: Chinese between Catalan and Croatian, Hindi
    // between Greek and Hungarian, Urdu last.
    let codes: Vec<&str> = Lang::all().iter().map(|l| l.code()).collect();
    let zh = codes.iter().position(|&c| c == "zh").expect("zh present");
    assert_eq!(codes[zh - 1], "ca", "Chinese follows Catalan");
    assert_eq!(codes[zh + 1], "hr", "Chinese precedes Croatian");
    let hi = codes.iter().position(|&c| c == "hi").expect("hi present");
    assert_eq!(codes[hi - 1], "el", "Hindi follows Greek");
    assert_eq!(codes[hi + 1], "hu", "Hindi precedes Hungarian");
}

#[test]
fn urdu_is_rtl_and_every_other_language_is_ltr() {
    // Urdu (Perso-Arabic) is the only right-to-left language. Iterating
    // all() also guards the wildcard in dir(): a future RTL variant added to
    // the enum but not to dir() fails here even though it compiles.
    assert_eq!(Lang::Ur.dir(), "rtl");
    for lang in Lang::all() {
        let expected = if lang == Lang::Ur { "rtl" } else { "ltr" };
        assert_eq!(lang.dir(), expected, "{} direction", lang.code());
    }
}

#[test]
fn asian_subtags_parse_without_an_alias_table() {
    assert_eq!(Lang::from_code("zh"), Some(Lang::Zh));
    assert_eq!(Lang::from_code("hi"), Some(Lang::Hi));
    assert_eq!(Lang::from_code("ur"), Some(Lang::Ur));
    // The primary-subtag split reduces every Chinese script/region variant to
    // `zh` — no alias arm needed. Traditional resolves to Simplified (a
    // documented limitation, asserted so it stays intentional).
    assert_eq!(Lang::from_code("zh-CN"), Some(Lang::Zh));
    assert_eq!(Lang::from_code("zh-Hans"), Some(Lang::Zh));
    assert_eq!(Lang::from_code("zh-Hant-TW"), Some(Lang::Zh));
    assert_eq!(Lang::from_code("ur-PK"), Some(Lang::Ur));
}

#[test]
fn codes_and_endonyms_are_all_distinct_and_nonempty() {
    let mut codes = std::collections::HashSet::new();
    let mut endonyms = std::collections::HashSet::new();
    for lang in Lang::all() {
        assert!(!lang.code().is_empty());
        assert!(!lang.endonym().is_empty());
        assert!(codes.insert(lang.code()), "duplicate code {}", lang.code());
        assert!(
            endonyms.insert(lang.endonym()),
            "duplicate endonym {}",
            lang.endonym()
        );
    }
}

#[test]
fn every_language_has_an_embedded_table() {
    for lang in Lang::all() {
        assert!(
            tables().contains_key(lang.code()),
            "no embedded TSV for {}",
            lang.code()
        );
    }
}

// ---------------------------------------------------------------------------
// The L×N coverage matrix and fallback semantics
// ---------------------------------------------------------------------------

#[test]
fn every_english_key_resolves_nonempty_in_every_language() {
    // The English table is the source of truth; `t` falls back to English
    // for any key a translation lacks, so this holds regardless of how
    // complete any machine translation is — and fails the build if a key is
    // dropped or the fallback chain breaks.
    let en = tables().get("en").expect("English table exists");
    assert!(!en.is_empty(), "English table must not be empty");
    for key in en.keys() {
        for lang in Lang::all() {
            let text = t(lang, key);
            assert!(!text.is_empty(), "{key} resolves empty in {}", lang.code());
            assert_ne!(
                text,
                *key,
                "{key} fell through to the raw key in {}",
                lang.code()
            );
        }
    }
}

#[test]
fn no_translation_carries_a_key_english_lacks() {
    // A key present in a translation but absent from English is dead weight
    // at best and a typo at worst.
    let en = tables().get("en").expect("English table exists");
    for lang in Lang::all() {
        let table = tables().get(lang.code()).expect("table exists");
        for key in table.keys() {
            assert!(
                en.contains_key(key),
                "{} carries orphan key {key}",
                lang.code()
            );
        }
    }
}

#[test]
fn missing_keys_fall_back_to_the_english_text() {
    // Find a language whose table lacks at least one English key (the
    // placeholder TSVs guarantee this until every translation is complete;
    // once all are complete this picks none and the loop is vacuous — the
    // dedicated parse-level test below still pins the mechanism).
    let en = tables().get("en").expect("English table exists");
    for lang in Lang::all() {
        let table = tables().get(lang.code()).expect("table exists");
        for (key, en_text) in en {
            if !table.contains_key(key) {
                assert_eq!(
                    t(lang, key),
                    *en_text,
                    "missing {key} in {} must yield the English TEXT",
                    lang.code()
                );
            }
        }
    }
}

#[test]
fn unknown_key_returns_the_key_itself() {
    assert_eq!(t(Lang::De, "no.such.key"), "no.such.key");
}

#[test]
fn placeholders_survive_in_every_translation() {
    // Six keys carry `{placeholder}` tokens the call sites substitute; a
    // translation that drops or mangles one breaks the rendered page.
    let en = tables().get("en").expect("English table exists");
    for (key, en_text) in en {
        let mut placeholders: Vec<&str> = Vec::new();
        let mut rest = *en_text;
        while let Some(start) = rest.find('{') {
            if let Some(len) = rest[start..].find('}') {
                placeholders.push(&rest[start..=start + len]);
                rest = &rest[start + len + 1..];
            } else {
                break;
            }
        }
        if placeholders.is_empty() {
            continue;
        }
        for lang in Lang::all() {
            let text = t(lang, key);
            for ph in &placeholders {
                assert!(
                    text.contains(ph),
                    "{key} in {} lost placeholder {ph}",
                    lang.code()
                );
            }
        }
    }
}

#[test]
fn protocol_fixed_vocabulary_is_identical_in_every_language() {
    // These keys are deliberately untranslated — protocol/product vocabulary
    // (CSAF, VEX, TLP, CVSS, CVE, CWE, NATO, SPDX, JSON, format names) that
    // must read identically in every language. Derived from the original
    // five human-maintained tables, where each already held one identical
    // value by design.
    const FIXED: &[&str] = &[
        "admin.format_csv_label",
        "admin.format_json_label",
        "admin.format_markdown_label",
        "admin.format_sarif_label",
        "csaf.field_cve",
        "csaf.field_cwe_id",
        "csaf.field_nato",
        "csaf.field_tlp",
        "csaf.field_verschlusssache",
        "csaf.form_section_cvss_v3",
        "csaf.form_section_cvss_v4",
        "csaf.list_col_cvss_v3",
        "csaf.list_col_cvss_v4",
        "csaf.opt_category_vex",
        "csaf.view_json_button",
        "csaf.view_row_tlp",
        "csaf.view_vuln_col_cve",
        "info.license_spdx_label",
        "nav.csaf",
        "nav.dashboard",
        "nav.info",
    ];
    for key in FIXED {
        let english = t(Lang::En, key);
        assert_ne!(english, *key, "{key} must exist in the English table");
        for lang in Lang::all() {
            assert_eq!(
                t(lang, key),
                english,
                "{key} must stay protocol-fixed (untranslated) in {}",
                lang.code()
            );
        }
    }
}

// ---------------------------------------------------------------------------
// parse_tsv edge cases (a real cargo-mutants survivor until these existed)
// ---------------------------------------------------------------------------

#[test]
fn parse_tsv_skips_comments_and_blanks_and_splits_on_first_tab() {
    let table = parse_tsv("# header\twith a tab\n\nnav.a\tHello\tworld\nnav.b\tX\n");
    assert_eq!(table.len(), 2);
    // Split on the FIRST tab: the remainder keeps its embedded tab.
    assert_eq!(table.get("nav.a"), Some(&"Hello\tworld"));
    assert_eq!(table.get("nav.b"), Some(&"X"));
}

#[test]
fn parse_tsv_drops_lines_without_a_tab() {
    let table = parse_tsv("no-tab-here\nnav.a\tok\n");
    assert_eq!(table.len(), 1);
    assert_eq!(table.get("nav.a"), Some(&"ok"));
}

// ---------------------------------------------------------------------------
// Resolver: cookie, Accept-Language, and the HTTP/2 split-cookie regression
// ---------------------------------------------------------------------------

#[test]
fn cookie_beats_accept_language() {
    assert_eq!(resolve(Some("csaf_lang=de"), Some("fr")), Lang::De);
}

#[test]
fn accept_language_used_when_no_cookie() {
    assert_eq!(resolve(None, Some("es,en;q=0.5")), Lang::Es);
    assert_eq!(resolve(None, Some("nb-NO,en;q=0.7")), Lang::No);
}

#[test]
fn accept_language_excludes_q_zero_languages() {
    assert_eq!(resolve(None, Some("de;q=0, fr")), Lang::Fr);
}

#[test]
fn unresolvable_headers_fall_back_to_english() {
    assert_eq!(resolve(None, None), Lang::En);
    assert_eq!(resolve(Some("other=1"), Some("xx-XX")), Lang::En);
}

#[test]
fn cookie_parsing_finds_the_pair_among_others() {
    assert_eq!(resolve(Some("a=1; csaf_lang=it; b=2"), None), Lang::It);
}

#[test]
fn malformed_cookie_pairs_do_not_panic_or_match() {
    assert_eq!(resolve(Some("csaf_lang; =de; ==="), None), Lang::En);
}

/// The `csaf_lang` cookie is found even when the client splits its cookie
/// pairs across SEVERAL `cookie` header fields — the Chrome-over-HTTP/2
/// shape (RFC 9113 §8.2.3 "cookie crumbling"). A dev browser on `127.0.0.1`
/// carries cookies from every app ever run on that host, so the language
/// cookie routinely arrives in a later field; reading only the first field
/// makes every language switch silently do nothing in a real browser while
/// curl stays green.
#[test]
fn from_parts_reads_every_cookie_header_field() {
    let (parts, ()) = Request::builder()
        .header(COOKIE, "unrelated_app=1")
        .header(COOKIE, "csaf_lang=de")
        .body(())
        .expect("request builds")
        .into_parts();
    assert_eq!(from_parts(&parts), Lang::De);
}

// ---------------------------------------------------------------------------
// Property tests
// ---------------------------------------------------------------------------

proptest! {
    /// `resolve` is total over attacker-supplied headers: always a Lang,
    /// never a panic.
    #[test]
    fn resolve_is_total_over_arbitrary_headers(
        cookie in prop::option::of(".*"),
        accept in prop::option::of(".*"),
    ) {
        prop_assert!(Lang::all().contains(&resolve(cookie.as_deref(), accept.as_deref())));
    }

    /// `from_code` never panics, and any parse it accepts round-trips to the
    /// same primary subtag — with the documented `nb -> no` alias as the ONE
    /// exception.
    #[test]
    fn from_code_round_trips_with_the_nb_alias_exception(junk in ".*") {
        if let Some(parsed) = Lang::from_code(&junk) {
            let primary = junk
                .split(['-', '_'])
                .next()
                .unwrap_or(&junk)
                .to_ascii_lowercase();
            let aliased = primary == "nb" && parsed.code() == "no";
            prop_assert!(primary == parsed.code() || aliased);
        }
    }

    /// The cookie pin wins over Accept-Language for EVERY supported language.
    #[test]
    fn the_cookie_pin_wins(idx in 0usize..49) {
        let lang = Lang::all()[idx];
        let cookie = format!("csaf_lang={}", lang.code());
        prop_assert_eq!(resolve(Some(&cookie), Some("zz,qq")), lang);
    }

    /// `t` is total: any key in any language yields a non-empty string or
    /// the key itself, never a panic.
    #[test]
    fn t_is_total(idx in 0usize..49, key in "[a-z._]{0,40}") {
        let lang = Lang::all()[idx];
        let leaked: &'static str = Box::leak(key.into_boxed_str());
        let text = t(lang, leaked);
        prop_assert!(!text.is_empty() || leaked.is_empty());
    }
}