marque_ism/marking_forms.rs
1// SPDX-FileCopyrightText: 2026 Knitli Inc.
2//
3// SPDX-License-Identifier: LicenseRef-MarqueLicense-1.0
4
5//! Single source of truth for banner ↔ portion marking form mappings.
6//!
7//! The CAPCO Register (CAPCO-2016 §G.1 Table 4, lines 821–841) defines three
8//! columns per marking:
9//!
10//! - **Marking Title** (full descriptive name, e.g., "NOT RELEASABLE TO FOREIGN NATIONALS")
11//! - **Banner Line Abbreviation** (e.g., "NOFORN")
12//! - **Portion Mark** (e.g., "NF")
13//!
14//! For most markings, banner and portion forms are identical (e.g., HCS, FISA,
15//! RELIDO). This module only tracks entries where the forms *differ*, since
16//! those are the ones E001 (banner uses portion abbreviation) and E009 (portion
17//! uses banner expansion) need to detect and correct.
18//!
19//! Per CAPCO-2016 §A.6 line 317, a banner line may spell out the Marking Title
20//! OR use the Authorized Abbreviation — both are valid. Detection of the long
21//! title in a banner is driven by the [`MarkingForm::title`] field and owned
22//! by the S001 `prefer-banner-abbreviation` style rule. `title == banner` when
23//! a marking has no distinct abbreviation (e.g., `DEA SENSITIVE`, whose
24//! Register row shows `None` under the abbreviation column); S001 must not
25//! fire on those.
26//!
27//! Classification levels (TOP SECRET ↔ TS, etc.) are handled separately by
28//! [`crate::Classification::banner_str`] / [`crate::Classification::portion_str`]
29//! because they follow a different structural pattern (banners use full words
30//! with no abbreviation, not a shortened form).
31//!
32//! # Maintenance
33//!
34//! This table is hand-maintained from the CAPCO Register. The ODNI CVE XML
35//! schemas only carry the portion-form codes; banner abbreviations and titles
36//! are a CAPCO marking convention not encoded in the XML. When ODNI publishes
37//! a new register, update this table and bump the schema version in
38//! `crates/ism/Cargo.toml`.
39
40/// A marking where the banner-line abbreviation differs from the portion mark.
41///
42/// Fields correspond to the three columns of CAPCO-2016 §G.1 Table 4.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct MarkingForm {
45 /// Long "Authorized Banner Line Marking Title" form (§G.1 Table 4,
46 /// column 1), e.g., "NOT RELEASABLE TO FOREIGN NATIONALS".
47 ///
48 /// Equals [`Self::banner`] when the Register lists `None` under the
49 /// abbreviation column (e.g., `DEA SENSITIVE`), meaning the marking has
50 /// no distinct abbreviation. S001 uses `title != banner` to gate its
51 /// fix proposal.
52 pub title: &'static str,
53 /// "Authorized Banner Line Abbreviation" form (§G.1 Table 4, column 2),
54 /// e.g., "NOFORN", "ORCON". Equals [`Self::title`] when the Register
55 /// lists no distinct abbreviation.
56 pub banner: &'static str,
57 /// "Authorized Portion Mark" form (§G.1 Table 4, column 3), e.g., "NF",
58 /// "OC".
59 pub portion: &'static str,
60}
61
62/// All markings where the long Marking Title differs from the banner
63/// abbreviation or portion mark.
64///
65/// Source: CAPCO Register (Implementation Manual for the IC, current edition).
66///
67/// Sections covered:
68/// - §H.4 SCI Control System Markings (long-title forms only)
69/// - §H.6 Atomic Energy Act Information Markings
70/// - §H.8 Dissemination Control Markings
71/// - §H.9 Non-IC Dissemination Control Markings
72///
73/// Two kinds of entries are included:
74///
75/// 1. **Differing-form entries** (`title != banner || banner != portion`): E001
76/// (banner uses portion abbreviation) and E009 (portion uses banner expansion)
77/// need these to detect and correct cross-form usage.
78///
79/// 2. **Same-form entries** (`banner == portion` but `title != banner`): S001
80/// fires when it sees the long Marking Title used in a banner line instead
81/// of the authorized abbreviation (e.g. "FOR OFFICIAL USE ONLY" → "FOUO").
82/// Without an entry here, S001 cannot detect the substitution opportunity.
83/// `title == banner` entries (e.g. `DEA SENSITIVE`) are still included when
84/// the portion mark differs, but S001 skips them (no substitution possible).
85pub static MARKING_FORMS: &[MarkingForm] = &[
86 // §H.4 SCI Control System Markings — long-title forms.
87 // CAPCO-2016 §H.4 p73 defines full names for control systems. Banner and
88 // portion forms are identical (e.g. TK, HCS, SI), so these are same-form
89 // entries; only S001 uses them. Titles verified against §H.4 headings.
90 MarkingForm {
91 title: "TALENT KEYHOLE",
92 banner: "TK",
93 portion: "TK",
94 },
95 // §H.6 Atomic Energy Act Information Markings.
96 // Long Marking Titles from CAPCO-2016 §H.6 p113–122. Banner and portion
97 // forms are identical for RD, FRD, TFNI, CNWDI — same-form entries for
98 // S001 detection. SIGMA [##] ↔ SG [##] is parametric and handled
99 // separately by the parser's pattern-matching path, not this table.
100 // DOD/DOE UCNI have differing forms and are entries of the first kind.
101 MarkingForm {
102 title: "RESTRICTED DATA",
103 banner: "RD",
104 portion: "RD",
105 },
106 MarkingForm {
107 title: "FORMERLY RESTRICTED DATA",
108 banner: "FRD",
109 portion: "FRD",
110 },
111 MarkingForm {
112 title: "TRANSCLASSIFIED FOREIGN NUCLEAR INFORMATION",
113 banner: "TFNI",
114 portion: "TFNI",
115 },
116 MarkingForm {
117 title: "CRITICAL NUCLEAR WEAPON DESIGN INFORMATION",
118 banner: "CNWDI",
119 portion: "CNWDI",
120 },
121 MarkingForm {
122 title: "DOD UNCLASSIFIED CONTROLLED NUCLEAR INFORMATION",
123 banner: "DOD UCNI",
124 portion: "DCNI",
125 },
126 MarkingForm {
127 title: "DOE UNCLASSIFIED CONTROLLED NUCLEAR INFORMATION",
128 banner: "DOE UCNI",
129 portion: "UCNI",
130 },
131 // §H.8 Dissemination Control Markings.
132 //
133 // Titles below are transcribed from CAPCO-2016 §G.1 Table 4 (lines
134 // 821–841). Each row uses columns (Title | Abbreviation | Portion).
135 MarkingForm {
136 title: "NOT RELEASABLE TO FOREIGN NATIONALS",
137 banner: "NOFORN",
138 portion: "NF",
139 },
140 MarkingForm {
141 title: "ORIGINATOR CONTROLLED-USGOV",
142 banner: "ORCON-USGOV",
143 portion: "OC-USGOV",
144 },
145 MarkingForm {
146 title: "ORIGINATOR CONTROLLED",
147 banner: "ORCON",
148 portion: "OC",
149 },
150 MarkingForm {
151 title: "CONTROLLED IMAGERY",
152 banner: "IMCON",
153 portion: "IMC",
154 },
155 MarkingForm {
156 title: "CAUTION-PROPRIETARY INFORMATION INVOLVED",
157 banner: "PROPIN",
158 portion: "PR",
159 },
160 MarkingForm {
161 title: "RISK SENSITIVE",
162 banner: "RSEN",
163 portion: "RS",
164 },
165 MarkingForm {
166 // §G.1 Table 4 line 831: `| DEA SENSITIVE | None | DSEN |`. No
167 // distinct banner abbreviation — `title == banner`. S001 must
168 // skip this row (no substitution possible).
169 title: "DEA SENSITIVE",
170 banner: "DEA SENSITIVE",
171 portion: "DSEN",
172 },
173 // §H.8 same-form entries: banner == portion, but title differs.
174 // S001 fires when a banner line spells out the Marking Title instead
175 // of the authorized abbreviation. §G.1 Table 4 / §H.8 p157–171.
176 MarkingForm {
177 title: "FOR OFFICIAL USE ONLY",
178 banner: "FOUO",
179 portion: "FOUO",
180 },
181 MarkingForm {
182 title: "RELEASABLE BY INFORMATION DISCLOSURE OFFICIAL",
183 banner: "RELIDO",
184 portion: "RELIDO",
185 },
186 MarkingForm {
187 title: "FOREIGN INTELLIGENCE SURVEILLANCE ACT",
188 banner: "FISA",
189 portion: "FISA",
190 },
191 // §H.9 Non-IC Dissemination Control Markings.
192 MarkingForm {
193 title: "LIMITED DISTRIBUTION",
194 banner: "LIMDIS",
195 portion: "DS",
196 },
197 MarkingForm {
198 title: "EXCLUSIVE DISTRIBUTION",
199 banner: "EXDIS",
200 portion: "XD",
201 },
202 MarkingForm {
203 title: "NO DISTRIBUTION",
204 banner: "NODIS",
205 portion: "ND",
206 },
207 // §H.9 same-form entries: banner == portion, but title differs.
208 MarkingForm {
209 title: "SENSITIVE BUT UNCLASSIFIED",
210 banner: "SBU",
211 portion: "SBU",
212 },
213 MarkingForm {
214 title: "SENSITIVE BUT UNCLASSIFIED NOFORN",
215 banner: "SBU NOFORN",
216 portion: "SBU-NF",
217 },
218 MarkingForm {
219 title: "LAW ENFORCEMENT SENSITIVE",
220 banner: "LES",
221 portion: "LES",
222 },
223 MarkingForm {
224 title: "LAW ENFORCEMENT SENSITIVE NOFORN",
225 banner: "LES NOFORN",
226 portion: "LES-NF",
227 },
228 MarkingForm {
229 title: "SENSITIVE SECURITY INFORMATION",
230 banner: "SSI",
231 portion: "SSI",
232 },
233];
234
235/// Look up the portion-form abbreviation for a banner-form string.
236///
237/// Used by:
238/// - E009 (portion-abbreviation): detects banner forms in portions, suggests abbreviation
239/// - Parser (`parse_dissem_full_form`): accepts banner-form input and maps to CVE code
240///
241/// Returns `None` if the input is not a known banner form, or if it is a
242/// same-form entry (`banner == portion`, e.g., `LES`, `SBU`, `FOUO`) because
243/// there is no distinct portion abbreviation to substitute.
244/// Note: `NOFORN` is **not** a same-form entry — in [`MARKING_FORMS`] it maps
245/// banner `NOFORN` → portion `NF`, so this function returns `Some("NF")` for it.
246/// Same-form entries return `None` here; during parsing, long-title inputs are
247/// resolved via `title_to_portion`, while abbreviation inputs are already
248/// handled by `DissemControl::parse`.
249pub fn banner_to_portion(banner: &str) -> Option<&'static str> {
250 MARKING_FORMS
251 .iter()
252 .find(|f| f.banner == banner && f.banner != f.portion)
253 .map(|f| f.portion)
254}
255
256/// Look up the banner-form expansion for a portion-form abbreviation.
257///
258/// Used by:
259/// - E001 (portion-mark-in-banner): detects portion marks used in banner lines, suggests banner abbreviation
260///
261/// Returns `None` if the input is not a known portion form that has a *distinct*
262/// banner form (`banner != portion`). Same-form entries (e.g., `LES`, `SBU`,
263/// `FOUO`, `FISA`, `RELIDO`) return `None` because there is no substitution to
264/// make — E001 must not fire a no-op fix for them.
265pub fn portion_to_banner(portion: &str) -> Option<&'static str> {
266 MARKING_FORMS
267 .iter()
268 .find(|f| f.portion == portion && f.banner != f.portion)
269 .map(|f| f.banner)
270}
271
272/// Look up the portion-form abbreviation for a long "Marking Title" string.
273///
274/// Used by:
275/// - Parser (`parse_dissem_full_form`): accepts long-title input like
276/// `"NOT RELEASABLE TO FOREIGN NATIONALS"` and maps to the same
277/// `DissemControl` the abbreviation would produce.
278///
279/// Returns `None` if the input is not a known title, or if the marking has
280/// no distinct banner abbreviation (`title == banner`). The second case
281/// avoids shadowing the dedicated `banner_to_portion` path for inputs like
282/// `"DEA SENSITIVE"`.
283pub fn title_to_portion(title: &str) -> Option<&'static str> {
284 MARKING_FORMS
285 .iter()
286 .find(|f| f.title == title && f.title != f.banner)
287 .map(|f| f.portion)
288}
289
290/// Look up the banner-line abbreviation for a long "Marking Title" string.
291///
292/// Used by:
293/// - S001 (prefer-banner-abbreviation): detects long-title forms in banner
294/// markings and proposes the abbreviation as a style fix.
295///
296/// Returns `None` when no substitution is possible — either the input is
297/// unknown, or the marking has no distinct abbreviation (`title == banner`,
298/// e.g., `DEA SENSITIVE`). The second case is deliberate: S001 must not
299/// fire on rows where the Register lists no abbreviation.
300pub fn title_to_banner(title: &str) -> Option<&'static str> {
301 MARKING_FORMS
302 .iter()
303 .find(|f| f.title == title && f.title != f.banner)
304 .map(|f| f.banner)
305}
306
307#[cfg(test)]
308#[cfg_attr(coverage_nightly, coverage(off))]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn banner_to_portion_known_entries() {
314 assert_eq!(banner_to_portion("NOFORN"), Some("NF"));
315 assert_eq!(banner_to_portion("ORCON"), Some("OC"));
316 assert_eq!(banner_to_portion("IMCON"), Some("IMC"));
317 assert_eq!(banner_to_portion("DEA SENSITIVE"), Some("DSEN"));
318 assert_eq!(banner_to_portion("PROPIN"), Some("PR"));
319 assert_eq!(banner_to_portion("RSEN"), Some("RS"));
320 assert_eq!(banner_to_portion("LIMDIS"), Some("DS"));
321 assert_eq!(banner_to_portion("EXDIS"), Some("XD"));
322 assert_eq!(banner_to_portion("NODIS"), Some("ND"));
323 assert_eq!(banner_to_portion("SBU NOFORN"), Some("SBU-NF"));
324 assert_eq!(banner_to_portion("LES NOFORN"), Some("LES-NF"));
325 assert_eq!(banner_to_portion("DOD UCNI"), Some("DCNI"));
326 assert_eq!(banner_to_portion("DOE UCNI"), Some("UCNI"));
327 }
328
329 #[test]
330 fn portion_to_banner_known_entries() {
331 assert_eq!(portion_to_banner("NF"), Some("NOFORN"));
332 assert_eq!(portion_to_banner("OC"), Some("ORCON"));
333 assert_eq!(portion_to_banner("IMC"), Some("IMCON"));
334 assert_eq!(portion_to_banner("DSEN"), Some("DEA SENSITIVE"));
335 assert_eq!(portion_to_banner("PR"), Some("PROPIN"));
336 assert_eq!(portion_to_banner("RS"), Some("RSEN"));
337 assert_eq!(portion_to_banner("DS"), Some("LIMDIS"));
338 assert_eq!(portion_to_banner("XD"), Some("EXDIS"));
339 // spellchecker:ignore-next-line
340 assert_eq!(portion_to_banner("ND"), Some("NODIS"));
341 assert_eq!(portion_to_banner("SBU-NF"), Some("SBU NOFORN"));
342 assert_eq!(portion_to_banner("LES-NF"), Some("LES NOFORN"));
343 assert_eq!(portion_to_banner("DCNI"), Some("DOD UCNI"));
344 assert_eq!(portion_to_banner("UCNI"), Some("DOE UCNI"));
345 }
346
347 #[test]
348 fn banner_to_portion_returns_none_for_unknown() {
349 assert_eq!(banner_to_portion("BANANAPHONE"), None);
350 }
351
352 #[test]
353 fn portion_to_banner_returns_none_for_unknown() {
354 assert_eq!(portion_to_banner("BANANAPHONE"), None);
355 }
356
357 #[test]
358 fn banner_to_portion_returns_none_for_portion_form() {
359 // Passing a portion form to banner_to_portion should not match.
360 assert_eq!(banner_to_portion("NF"), None);
361 assert_eq!(banner_to_portion("OC"), None);
362 }
363
364 #[test]
365 fn portion_to_banner_returns_none_for_banner_form() {
366 // Passing a banner form to portion_to_banner should not match.
367 assert_eq!(portion_to_banner("NOFORN"), None);
368 assert_eq!(portion_to_banner("ORCON"), None);
369 }
370
371 #[test]
372 fn same_form_entries_return_none_from_conversion_helpers() {
373 // Same-form entries (banner == portion) must return None from both
374 // helpers so E001/E009 never fire a no-op substitution fix for them.
375 // Regression guard for PR #256.
376 for &same_form in &[
377 "FOUO", "RELIDO", "FISA", "SBU", "LES", "SSI", "TK", "RD", "FRD", "TFNI", "CNWDI",
378 ] {
379 assert_eq!(
380 banner_to_portion(same_form),
381 None,
382 "banner_to_portion({same_form:?}) should be None for same-form entry"
383 );
384 assert_eq!(
385 portion_to_banner(same_form),
386 None,
387 "portion_to_banner({same_form:?}) should be None for same-form entry"
388 );
389 }
390 }
391
392 #[test]
393 fn no_duplicate_banner_entries() {
394 for (i, a) in MARKING_FORMS.iter().enumerate() {
395 for (j, b) in MARKING_FORMS.iter().enumerate() {
396 if i != j {
397 assert_ne!(a.banner, b.banner, "duplicate banner entry: {:?}", a.banner);
398 }
399 }
400 }
401 }
402
403 #[test]
404 fn no_duplicate_portion_entries() {
405 for (i, a) in MARKING_FORMS.iter().enumerate() {
406 for (j, b) in MARKING_FORMS.iter().enumerate() {
407 if i != j {
408 assert_ne!(
409 a.portion, b.portion,
410 "duplicate portion entry: {:?}",
411 a.portion
412 );
413 }
414 }
415 }
416 }
417
418 #[test]
419 fn banner_and_portion_forms_are_valid() {
420 for f in MARKING_FORMS {
421 if f.banner != f.portion {
422 // Differing-form entries: E001/E009 use cases. The banner and
423 // portion abbreviations are distinct (e.g. NOFORN/NF, ORCON/OC).
424 // Nothing further to assert here — the differ is the invariant.
425 } else {
426 // Same-form entries: S001 use case only. Banner and portion
427 // abbreviations are identical, but the long title MUST differ
428 // from the abbreviation so S001 has something to detect.
429 assert_ne!(
430 f.title, f.banner,
431 "same-form entry has title equal to banner (S001 would never fire): {:?}",
432 f.banner
433 );
434 }
435 }
436 }
437
438 // T035c-1b: title-column lookups for the S001 style rule and the
439 // parser's long-title acceptance path.
440
441 #[test]
442 fn title_to_portion_known_entries() {
443 assert_eq!(
444 title_to_portion("NOT RELEASABLE TO FOREIGN NATIONALS"),
445 Some("NF")
446 );
447 assert_eq!(title_to_portion("ORIGINATOR CONTROLLED"), Some("OC"));
448 assert_eq!(title_to_portion("CONTROLLED IMAGERY"), Some("IMC"));
449 assert_eq!(
450 title_to_portion("CAUTION-PROPRIETARY INFORMATION INVOLVED"),
451 Some("PR")
452 );
453 assert_eq!(title_to_portion("RISK SENSITIVE"), Some("RS"));
454 assert_eq!(title_to_portion("LIMITED DISTRIBUTION"), Some("DS"));
455 assert_eq!(title_to_portion("EXCLUSIVE DISTRIBUTION"), Some("XD"));
456 assert_eq!(title_to_portion("NO DISTRIBUTION"), Some("ND"));
457 assert_eq!(
458 title_to_portion("SENSITIVE BUT UNCLASSIFIED NOFORN"),
459 Some("SBU-NF")
460 );
461 assert_eq!(
462 title_to_portion("LAW ENFORCEMENT SENSITIVE NOFORN"),
463 Some("LES-NF")
464 );
465 assert_eq!(
466 title_to_portion("DOD UNCLASSIFIED CONTROLLED NUCLEAR INFORMATION"),
467 Some("DCNI")
468 );
469 assert_eq!(
470 title_to_portion("DOE UNCLASSIFIED CONTROLLED NUCLEAR INFORMATION"),
471 Some("UCNI")
472 );
473 }
474
475 #[test]
476 fn title_to_banner_known_entries() {
477 assert_eq!(
478 title_to_banner("NOT RELEASABLE TO FOREIGN NATIONALS"),
479 Some("NOFORN")
480 );
481 assert_eq!(title_to_banner("ORIGINATOR CONTROLLED"), Some("ORCON"));
482 assert_eq!(title_to_banner("CONTROLLED IMAGERY"), Some("IMCON"));
483 assert_eq!(
484 title_to_banner("CAUTION-PROPRIETARY INFORMATION INVOLVED"),
485 Some("PROPIN")
486 );
487 assert_eq!(title_to_banner("RISK SENSITIVE"), Some("RSEN"));
488 assert_eq!(title_to_banner("LIMITED DISTRIBUTION"), Some("LIMDIS"));
489 assert_eq!(title_to_banner("EXCLUSIVE DISTRIBUTION"), Some("EXDIS"));
490 assert_eq!(title_to_banner("NO DISTRIBUTION"), Some("NODIS"));
491 }
492
493 #[test]
494 fn title_lookups_return_none_for_dea_sensitive() {
495 // CAPCO-2016 §G.1 Table 4 line 831: DEA SENSITIVE has no
496 // distinct banner abbreviation (`| DEA SENSITIVE | None | DSEN |`).
497 // The `title == banner` guard in the lookups must skip this row
498 // so S001 does not propose a no-op substitution and the parser
499 // does not double-resolve the banner-form path.
500 assert_eq!(title_to_portion("DEA SENSITIVE"), None);
501 assert_eq!(title_to_banner("DEA SENSITIVE"), None);
502 }
503
504 #[test]
505 fn title_lookups_return_none_for_unknown() {
506 assert_eq!(title_to_portion("BANANAPHONE"), None);
507 assert_eq!(title_to_banner("BANANAPHONE"), None);
508 // A banner abbreviation string (not a title) must not match
509 // title lookups.
510 assert_eq!(title_to_portion("NOFORN"), None);
511 assert_eq!(title_to_banner("NOFORN"), None);
512 }
513
514 #[test]
515 fn no_duplicate_title_entries() {
516 for (i, a) in MARKING_FORMS.iter().enumerate() {
517 for (j, b) in MARKING_FORMS.iter().enumerate() {
518 if i != j {
519 assert_ne!(a.title, b.title, "duplicate title entry: {:?}", a.title);
520 }
521 }
522 }
523 }
524
525 #[test]
526 fn dea_sensitive_is_the_only_title_equal_banner() {
527 // Guards against future ODNI register changes that might
528 // introduce new rows without a distinct abbreviation. If one
529 // lands, update S001's pin-down tests and this guard.
530 let same_form: Vec<&'static str> = MARKING_FORMS
531 .iter()
532 .filter(|f| f.title == f.banner)
533 .map(|f| f.title)
534 .collect();
535 assert_eq!(
536 same_form,
537 vec!["DEA SENSITIVE"],
538 "only DEA SENSITIVE should have `title == banner` today \
539 (CAPCO-2016 §G.1 Table 4 line 831). If this fails, a new \
540 row without a distinct abbreviation has been added — \
541 update S001 tests accordingly."
542 );
543 }
544}