Skip to main content

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}