Skip to main content

compose_lens/model/
security_option.rs

1//! Raw-preserving service security options.
2
3use crate::source::SourceSpan;
4
5use super::Located;
6
7/// The narrow native classification of one service `security_opt` string.
8#[derive(Debug, Clone, PartialEq, Eq)]
9#[non_exhaustive]
10pub enum SecurityOptionKind {
11    /// An exact lowercase `apparmor=<profile>` candidate with no whitespace alteration.
12    AppArmor {
13        /// The exact non-empty profile spelling after `apparmor=`.
14        profile: String,
15    },
16    /// An exact lowercase `seccomp=<profile>` candidate with no whitespace alteration.
17    Seccomp {
18        /// The exact non-empty profile spelling after `seccomp=`.
19        profile: String,
20    },
21    /// An exact lowercase whitespace-free `no-new-privileges:<bool>` candidate.
22    NoNewPrivileges {
23        /// Whether no-new-privileges is explicitly enabled or disabled.
24        enabled: bool,
25    },
26    /// An exact lowercase whitespace-free `label:disable` candidate.
27    SecurityLabelDisable {
28        /// Whether `SELinux` label separation is explicitly disabled.
29        enabled: bool,
30    },
31    /// An exact lowercase whitespace-free `label:filetype:<type>` candidate.
32    SecurityLabelFileType {
33        /// The exact non-empty file-type spelling after `label:filetype:`.
34        file_type: String,
35    },
36    /// An exact lowercase whitespace-free `label:level:<level>` candidate.
37    SecurityLabelLevel {
38        /// The exact non-empty level spelling after `label:level:`.
39        level: String,
40    },
41    /// An exact lowercase whitespace-free `label:nested` candidate.
42    SecurityLabelNested {
43        /// Whether nested `SELinux` labeling is explicitly requested.
44        enabled: bool,
45    },
46    /// An exact lowercase whitespace-free `label:type:<type>` candidate.
47    SecurityLabelType {
48        /// The exact non-empty type spelling after `label:type:`.
49        label_type: String,
50    },
51    /// An exact lowercase whitespace-free `mask=<paths>` candidate.
52    Mask {
53        /// The exact non-empty path payload after `mask=`, including colon separators.
54        paths: String,
55    },
56    /// An exact lowercase whitespace-free `unmask=<paths>` candidate.
57    Unmask {
58        /// The exact payload after `unmask=`, either `ALL` or colon-separated absolute-looking paths.
59        paths: String,
60    },
61    /// A value whose effective spelling depends on Compose interpolation.
62    Expression,
63    /// An explicitly authored empty string.
64    Empty,
65    /// An AppArmor-shaped spelling that is not the exact narrow candidate form.
66    AppArmorNearMiss,
67    /// A seccomp-shaped spelling that is not the exact narrow candidate form.
68    SeccompNearMiss,
69    /// A no-new-privileges-shaped spelling that is not an exact narrow candidate.
70    NoNewPrivilegesNearMiss,
71    /// A label-disable-shaped spelling that is not the exact narrow candidate.
72    SecurityLabelDisableNearMiss,
73    /// A label-filetype-shaped spelling that is not the exact narrow candidate.
74    SecurityLabelFileTypeNearMiss,
75    /// A label-level-shaped spelling that is not the exact narrow candidate.
76    SecurityLabelLevelNearMiss,
77    /// A label-nested-shaped spelling that is not the exact narrow candidate.
78    SecurityLabelNestedNearMiss,
79    /// A label-type-shaped spelling that is not the exact narrow candidate.
80    SecurityLabelTypeNearMiss,
81    /// A mask-shaped spelling that is not the exact narrow candidate.
82    MaskNearMiss,
83    /// An unmask-shaped spelling that is not the exact narrow candidate.
84    UnmaskNearMiss,
85    /// Another raw provider option whose grammar is deliberately uninterpreted.
86    Other,
87}
88
89/// One exact source-aware service `security_opt` item.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct SecurityOptionItem {
92    raw: Located<String>,
93    kind: SecurityOptionKind,
94}
95
96impl SecurityOptionItem {
97    pub(crate) fn parse(raw: Located<String>) -> Self {
98        let kind = classify_security_option(raw.value());
99        Self { raw, kind }
100    }
101
102    /// Returns the exact semantic string spelling without separator or case normalization.
103    #[must_use]
104    pub fn value(&self) -> &str {
105        self.raw.value()
106    }
107
108    /// Returns the exact source span of this sequence item.
109    #[must_use]
110    pub const fn span(&self) -> SourceSpan {
111        self.raw.span()
112    }
113
114    /// Returns the conservative option classification.
115    #[must_use]
116    pub const fn kind(&self) -> &SecurityOptionKind {
117        &self.kind
118    }
119}
120
121/// An explicitly authored ordered service `security_opt` sequence.
122///
123/// Omission is represented by absence of this value. An empty item vector therefore retains an
124/// explicitly authored empty sequence.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct SecurityOptions {
127    span: SourceSpan,
128    items: Vec<SecurityOptionItem>,
129}
130
131#[derive(Debug, Default)]
132pub(crate) struct SecurityOptionCandidateCounts {
133    pub(crate) apparmor: usize,
134    pub(crate) seccomp: usize,
135    pub(crate) no_new_privileges: usize,
136    pub(crate) security_label_disable: usize,
137    pub(crate) security_label_filetype: usize,
138    pub(crate) security_label_level: usize,
139    pub(crate) security_label_nested: usize,
140    pub(crate) security_label_type: usize,
141}
142
143impl SecurityOptions {
144    pub(crate) const fn new(span: SourceSpan, items: Vec<SecurityOptionItem>) -> Self {
145        Self { span, items }
146    }
147
148    /// Returns the span of the complete authored sequence.
149    #[must_use]
150    pub const fn span(&self) -> SourceSpan {
151        self.span
152    }
153
154    /// Returns every valid string item in authored order, including duplicates and empty strings.
155    #[must_use]
156    pub fn items(&self) -> &[SecurityOptionItem] {
157        &self.items
158    }
159}
160
161pub(crate) fn classify_security_option(value: &str) -> SecurityOptionKind {
162    if value.contains('$') {
163        return SecurityOptionKind::Expression;
164    }
165    if value.is_empty() {
166        return SecurityOptionKind::Empty;
167    }
168    if let Some(profile) = value.strip_prefix("apparmor=") {
169        if !profile.is_empty() && !profile.chars().any(char::is_whitespace) {
170            return SecurityOptionKind::AppArmor {
171                profile: profile.to_owned(),
172            };
173        }
174        return SecurityOptionKind::AppArmorNearMiss;
175    }
176    if value
177        .trim()
178        .get(..8)
179        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("apparmor"))
180    {
181        return SecurityOptionKind::AppArmorNearMiss;
182    }
183    if let Some(profile) = value.strip_prefix("seccomp=") {
184        if !profile.is_empty() && !profile.chars().any(char::is_whitespace) {
185            return SecurityOptionKind::Seccomp {
186                profile: profile.to_owned(),
187            };
188        }
189        return SecurityOptionKind::SeccompNearMiss;
190    }
191    let compact = value
192        .chars()
193        .filter(|character| !character.is_whitespace())
194        .collect::<String>();
195    if let (Some(prefix), Some(suffix)) = (compact.get(..7), compact.get(7..))
196        && prefix.eq_ignore_ascii_case("seccomp")
197        && (suffix.is_empty() || suffix.starts_with(':') || suffix.starts_with('='))
198    {
199        return SecurityOptionKind::SeccompNearMiss;
200    }
201    if let Some(kind) = classify_exact_boolean_option(value) {
202        return kind;
203    }
204    if let (Some(prefix), Some(suffix)) = (compact.get(..17), compact.get(17..))
205        && prefix.eq_ignore_ascii_case("no-new-privileges")
206        && (suffix.is_empty() || suffix.starts_with(':') || suffix.starts_with('='))
207    {
208        return SecurityOptionKind::NoNewPrivilegesNearMiss;
209    }
210    if let Some(paths) = value.strip_prefix("mask=") {
211        return if !paths.is_empty() && !paths.chars().any(char::is_whitespace) {
212            SecurityOptionKind::Mask {
213                paths: paths.to_owned(),
214            }
215        } else {
216            SecurityOptionKind::MaskNearMiss
217        };
218    }
219    if is_mask_near_miss(value, &compact) {
220        return SecurityOptionKind::MaskNearMiss;
221    }
222    if let Some(paths) = value.strip_prefix("unmask=") {
223        return if valid_unmask_payload(paths) && !value.chars().any(char::is_whitespace) {
224            SecurityOptionKind::Unmask {
225                paths: paths.to_owned(),
226            }
227        } else {
228            SecurityOptionKind::UnmaskNearMiss
229        };
230    }
231    if is_unmask_near_miss(value, &compact) {
232        return SecurityOptionKind::UnmaskNearMiss;
233    }
234    classify_security_label_option(value, &compact).unwrap_or(SecurityOptionKind::Other)
235}
236
237fn valid_unmask_payload(paths: &str) -> bool {
238    paths == "ALL"
239        || paths
240            .split(':')
241            .all(|segment| !segment.is_empty() && segment.starts_with('/'))
242}
243
244fn is_mask_near_miss(value: &str, compact: &str) -> bool {
245    let Some(prefix) = compact.get(..4) else {
246        return false;
247    };
248    if !prefix.eq_ignore_ascii_case("mask") {
249        return false;
250    }
251    let suffix = &compact[4..];
252    suffix.is_empty()
253        || suffix.starts_with(['=', ':'])
254        || suffix
255            .chars()
256            .next()
257            .is_some_and(|delimiter| !delimiter.is_ascii_alphanumeric() && delimiter != '_')
258        || value
259            .trim_start()
260            .get(4..)
261            .and_then(|suffix| suffix.chars().next())
262            .is_some_and(char::is_whitespace)
263}
264
265fn is_unmask_near_miss(value: &str, compact: &str) -> bool {
266    let Some(prefix) = compact.get(..6) else {
267        return false;
268    };
269    if !prefix.eq_ignore_ascii_case("unmask") {
270        return false;
271    }
272    let suffix = &compact[6..];
273    suffix.is_empty()
274        || suffix.starts_with(['=', ':'])
275        || suffix
276            .chars()
277            .next()
278            .is_some_and(|delimiter| !delimiter.is_ascii_alphanumeric() && delimiter != '_')
279        || value
280            .trim_start()
281            .get(6..)
282            .and_then(|suffix| suffix.chars().next())
283            .is_some_and(char::is_whitespace)
284}
285
286fn classify_security_label_option(value: &str, compact: &str) -> Option<SecurityOptionKind> {
287    if let Some(file_type) = value.strip_prefix("label:filetype:") {
288        return Some(if !file_type.is_empty() && !value.chars().any(char::is_whitespace) {
289            SecurityOptionKind::SecurityLabelFileType {
290                file_type: file_type.to_owned(),
291            }
292        } else {
293            SecurityOptionKind::SecurityLabelFileTypeNearMiss
294        });
295    }
296    if let Some(level) = value.strip_prefix("label:level:") {
297        return Some(if !level.is_empty() && !value.chars().any(char::is_whitespace) {
298            SecurityOptionKind::SecurityLabelLevel {
299                level: level.to_owned(),
300            }
301        } else {
302            SecurityOptionKind::SecurityLabelLevelNearMiss
303        });
304    }
305    if let Some(label_type) = value.strip_prefix("label:type:") {
306        return Some(
307            if !label_type.is_empty() && !value.chars().any(char::is_whitespace) && !label_type.contains([':', '=']) {
308                SecurityOptionKind::SecurityLabelType {
309                    label_type: label_type.to_owned(),
310                }
311            } else {
312                SecurityOptionKind::SecurityLabelTypeNearMiss
313            },
314        );
315    }
316    if compact.eq_ignore_ascii_case("label")
317        || compact.eq_ignore_ascii_case("label=disable")
318        || compact.eq_ignore_ascii_case("label:disable")
319        || compact
320            .get(..14)
321            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("label:disable:"))
322    {
323        return Some(SecurityOptionKind::SecurityLabelDisableNearMiss);
324    }
325    let lowercase_compact = compact.to_ascii_lowercase();
326    if lowercase_compact == "label:filetype"
327        || lowercase_compact.starts_with("label:filetype:")
328        || lowercase_compact.starts_with("label:filetype=")
329        || lowercase_compact.starts_with("label=filetype:")
330        || lowercase_compact.starts_with("label=filetype=")
331    {
332        return Some(SecurityOptionKind::SecurityLabelFileTypeNearMiss);
333    }
334    if lowercase_compact == "label:level"
335        || lowercase_compact == "label=level"
336        || lowercase_compact.starts_with("label:level:")
337        || lowercase_compact.starts_with("label:level=")
338        || lowercase_compact.starts_with("label=level:")
339        || lowercase_compact.starts_with("label=level=")
340    {
341        return Some(SecurityOptionKind::SecurityLabelLevelNearMiss);
342    }
343    if is_security_label_nested_near_miss(value, &lowercase_compact) {
344        return Some(SecurityOptionKind::SecurityLabelNestedNearMiss);
345    }
346    if is_security_label_type_near_miss(&lowercase_compact) {
347        return Some(SecurityOptionKind::SecurityLabelTypeNearMiss);
348    }
349    None
350}
351
352fn classify_exact_boolean_option(value: &str) -> Option<SecurityOptionKind> {
353    match value {
354        "no-new-privileges:true" => Some(SecurityOptionKind::NoNewPrivileges { enabled: true }),
355        "no-new-privileges:false" => Some(SecurityOptionKind::NoNewPrivileges { enabled: false }),
356        "label:disable" => Some(SecurityOptionKind::SecurityLabelDisable { enabled: true }),
357        "label:nested" => Some(SecurityOptionKind::SecurityLabelNested { enabled: true }),
358        _ => None,
359    }
360}
361
362fn is_security_label_nested_near_miss(value: &str, lowercase_compact: &str) -> bool {
363    lowercase_compact == "nested"
364        || lowercase_compact == "label=nested"
365        || lowercase_compact.starts_with("label:nested:")
366        || lowercase_compact.starts_with("label:nested=")
367        || lowercase_compact.starts_with("label=nested:")
368        || lowercase_compact.starts_with("label=nested=")
369        || (lowercase_compact == "label:nested" && value != "label:nested")
370}
371
372fn is_security_label_type_near_miss(lowercase_compact: &str) -> bool {
373    lowercase_compact == "type"
374        || lowercase_compact == "label:type"
375        || lowercase_compact == "label=type"
376        || lowercase_compact.starts_with("label:type:")
377        || lowercase_compact.starts_with("label:type=")
378        || lowercase_compact.starts_with("label=type:")
379        || lowercase_compact.starts_with("label=type=")
380}
381
382#[cfg(test)]
383mod tests {
384    use super::{SecurityOptionKind, classify_security_option};
385
386    #[test]
387    fn classifies_only_exact_lowercase_whitespace_free_security_option_candidates() {
388        assert!(matches!(
389            classify_security_option("apparmor=profile-a"),
390            SecurityOptionKind::AppArmor { profile } if profile == "profile-a"
391        ));
392        assert_eq!(
393            classify_security_option("${SECURITY_OPT}"),
394            SecurityOptionKind::Expression
395        );
396        assert_eq!(classify_security_option(""), SecurityOptionKind::Empty);
397        for (value, expected) in [
398            ("seccomp=unconfined", "unconfined"),
399            ("seccomp=/workspace/seccomp.json", "/workspace/seccomp.json"),
400        ] {
401            assert!(matches!(
402                classify_security_option(value),
403                SecurityOptionKind::Seccomp { profile } if profile == expected
404            ));
405        }
406        assert_eq!(
407            classify_security_option("seccomp=${SECCOMP_PROFILE}"),
408            SecurityOptionKind::Expression
409        );
410        for value in [
411            "seccomp",
412            "seccomp:",
413            "seccomp:unconfined",
414            "seccomp=",
415            "Seccomp=unconfined",
416            " seccomp=unconfined",
417            "seccomp =unconfined",
418            "seccomp=profile name",
419        ] {
420            assert_eq!(classify_security_option(value), SecurityOptionKind::SeccompNearMiss);
421        }
422        assert_eq!(
423            classify_security_option("seccomp-extra=true"),
424            SecurityOptionKind::Other
425        );
426        for value in [
427            "apparmor=",
428            "apparmor=profile name",
429            "AppArmor=profile-a",
430            "apparmor:profile-a",
431            " apparmor=profile-a",
432        ] {
433            assert_eq!(classify_security_option(value), SecurityOptionKind::AppArmorNearMiss);
434        }
435        assert_eq!(
436            classify_security_option("no-new-privileges:true"),
437            SecurityOptionKind::NoNewPrivileges { enabled: true }
438        );
439        assert_eq!(
440            classify_security_option("no-new-privileges:false"),
441            SecurityOptionKind::NoNewPrivileges { enabled: false }
442        );
443        for value in [
444            "no-new-privileges",
445            "no-new-privileges=true",
446            "No-New-Privileges:true",
447            " no-new-privileges:true",
448            "no-new-privileges: true",
449        ] {
450            assert_eq!(
451                classify_security_option(value),
452                SecurityOptionKind::NoNewPrivilegesNearMiss
453            );
454        }
455        assert_eq!(
456            classify_security_option("no-new-privileges-extra:true"),
457            SecurityOptionKind::Other
458        );
459        assert_eq!(
460            classify_security_option("label:disable"),
461            SecurityOptionKind::SecurityLabelDisable { enabled: true }
462        );
463        for value in [
464            "label=disable",
465            "label:disable:false",
466            "Label:disable",
467            " label:disable",
468            "label : disable",
469            "label",
470        ] {
471            assert_eq!(
472                classify_security_option(value),
473                SecurityOptionKind::SecurityLabelDisableNearMiss
474            );
475        }
476        for value in ["label:user:USER", "label:role:ROLE"] {
477            assert_eq!(classify_security_option(value), SecurityOptionKind::Other);
478        }
479        assert!(matches!(
480            classify_security_option("label:type:TYPE"),
481            SecurityOptionKind::SecurityLabelType { label_type } if label_type == "TYPE"
482        ));
483        assert_eq!(
484            classify_security_option("label:${LABEL_MODE}"),
485            SecurityOptionKind::Expression
486        );
487    }
488
489    #[test]
490    fn classifies_repeatable_mask_candidates_without_interpreting_payload_paths() {
491        for (value, expected) in [
492            ("mask=/run/secrets", "/run/secrets"),
493            ("mask=/proc/acpi:/proc/kcore", "/proc/acpi:/proc/kcore"),
494            ("mask=relative:opaque=value", "relative:opaque=value"),
495        ] {
496            assert!(matches!(
497                classify_security_option(value),
498                SecurityOptionKind::Mask { paths } if paths == expected
499            ));
500        }
501        for value in [
502            "mask",
503            "mask=",
504            "mask:/run/secrets",
505            "Mask=/run/secrets",
506            "MASK=/run/secrets",
507            " mask=/run/secrets",
508            "mask =/run/secrets",
509            "mask=/run/secret path",
510            "mask-/run/secrets",
511        ] {
512            assert_eq!(classify_security_option(value), SecurityOptionKind::MaskNearMiss);
513        }
514        assert_eq!(classify_security_option("masking=true"), SecurityOptionKind::Other);
515        assert_eq!(classify_security_option("masking true"), SecurityOptionKind::Other);
516        assert_eq!(
517            classify_security_option("mask=${MASK_PATHS}"),
518            SecurityOptionKind::Expression
519        );
520    }
521
522    #[test]
523    fn classifies_only_exact_repeatable_unmask_candidates() {
524        for (value, expected) in [
525            ("unmask=ALL", "ALL"),
526            ("unmask=/proc/acpi", "/proc/acpi"),
527            ("unmask=/proc/acpi:/sys/firmware", "/proc/acpi:/sys/firmware"),
528            ("unmask=/proc/*", "/proc/*"),
529        ] {
530            assert!(matches!(
531                classify_security_option(value),
532                SecurityOptionKind::Unmask { paths } if paths == expected
533            ));
534        }
535        for value in [
536            "unmask",
537            "unmask=",
538            "unmask=all",
539            "Unmask=ALL",
540            "UNMASK=/proc/acpi",
541            "unmask:/proc/acpi",
542            " unmask=/proc/acpi",
543            "unmask=/proc/acpi ",
544            "unmask =/proc/acpi",
545            "unmask=proc/acpi",
546            "unmask=/proc/acpi:relative",
547            "unmask=/proc/acpi:",
548            "unmask=:/proc/acpi",
549            "unmask=/proc/acpi::/sys/firmware",
550            "unmask=ALL:/proc/acpi",
551            "unmask=/proc/acpi:ALL",
552            "unmask-/proc/acpi",
553        ] {
554            assert_eq!(classify_security_option(value), SecurityOptionKind::UnmaskNearMiss);
555        }
556        assert_eq!(classify_security_option("unmasking=true"), SecurityOptionKind::Other);
557        assert_eq!(
558            classify_security_option("unmask=${UNMASK_PATHS}"),
559            SecurityOptionKind::Expression
560        );
561    }
562
563    #[test]
564    fn classifies_only_exact_label_filetype_candidates_and_precise_near_misses() {
565        assert!(matches!(
566            classify_security_option("label:filetype:container_file_t"),
567            SecurityOptionKind::SecurityLabelFileType { file_type }
568                if file_type == "container_file_t"
569        ));
570        for value in [
571            "label=filetype:container_file_t",
572            "label:filetype=container_file_t",
573            "Label:filetype:container_file_t",
574            "label:FileType:container_file_t",
575            " label:filetype:container_file_t",
576            "label:filetype:container file t",
577            "label:filetype:",
578            "label:filetype",
579        ] {
580            assert_eq!(
581                classify_security_option(value),
582                SecurityOptionKind::SecurityLabelFileTypeNearMiss
583            );
584        }
585        for value in ["label:user:USER", "label:role:ROLE"] {
586            assert_eq!(classify_security_option(value), SecurityOptionKind::Other);
587        }
588        assert!(matches!(
589            classify_security_option("label:type:TYPE"),
590            SecurityOptionKind::SecurityLabelType { label_type } if label_type == "TYPE"
591        ));
592        assert!(matches!(
593            classify_security_option("label:level:LEVEL"),
594            SecurityOptionKind::SecurityLabelLevel { level } if level == "LEVEL"
595        ));
596        assert_eq!(
597            classify_security_option("label:filetype:${LABEL_TYPE}"),
598            SecurityOptionKind::Expression
599        );
600    }
601
602    #[test]
603    fn classifies_only_exact_label_level_candidates_and_precise_near_misses() {
604        assert!(matches!(
605            classify_security_option("label:level:s0:c1,c2"),
606            SecurityOptionKind::SecurityLabelLevel { level }
607                if level == "s0:c1,c2"
608        ));
609        for value in [
610            "label=level:s0:c1,c2",
611            "label:level=s0:c1,c2",
612            "Label:level:s0:c1,c2",
613            "label:Level:s0:c1,c2",
614            " label:level:s0:c1,c2",
615            "label:level:s0 c1",
616            "label:level:",
617            "label:level",
618            "label=level",
619        ] {
620            assert_eq!(
621                classify_security_option(value),
622                SecurityOptionKind::SecurityLabelLevelNearMiss
623            );
624        }
625        for value in [
626            "label:type:TYPE",
627            "label:user:USER",
628            "label:role:ROLE",
629            "label:filetype:container_file_t",
630            "label:disable",
631        ] {
632            assert!(!matches!(
633                classify_security_option(value),
634                SecurityOptionKind::SecurityLabelLevel { .. } | SecurityOptionKind::SecurityLabelLevelNearMiss
635            ));
636        }
637        assert_eq!(
638            classify_security_option("label:level:${LABEL_LEVEL}"),
639            SecurityOptionKind::Expression
640        );
641    }
642
643    #[test]
644    fn classifies_only_exact_label_nested_candidates_and_precise_near_misses() {
645        assert_eq!(
646            classify_security_option("label:nested"),
647            SecurityOptionKind::SecurityLabelNested { enabled: true }
648        );
649        for value in [
650            "label=nested",
651            "Label:nested",
652            "label:Nested",
653            " label:nested",
654            "label : nested",
655            "label:nested:true",
656            "label:nested=",
657            "nested",
658        ] {
659            assert_eq!(
660                classify_security_option(value),
661                SecurityOptionKind::SecurityLabelNestedNearMiss
662            );
663        }
664        for value in [
665            "label:disable",
666            "label:filetype:container_file_t",
667            "label:level:s0:c1,c2",
668            "label:type:TYPE",
669            "label:user:USER",
670            "label:role:ROLE",
671        ] {
672            assert!(!matches!(
673                classify_security_option(value),
674                SecurityOptionKind::SecurityLabelNested { .. } | SecurityOptionKind::SecurityLabelNestedNearMiss
675            ));
676        }
677        assert_eq!(
678            classify_security_option("${LABEL_NESTED_OPTION}"),
679            SecurityOptionKind::Expression
680        );
681    }
682
683    #[test]
684    fn classifies_only_exact_label_type_candidates_and_precise_near_misses() {
685        assert!(matches!(
686            classify_security_option("label:type:container_t"),
687            SecurityOptionKind::SecurityLabelType { label_type }
688                if label_type == "container_t"
689        ));
690        assert!(matches!(
691            classify_security_option("label:type:TYPE"),
692            SecurityOptionKind::SecurityLabelType { label_type } if label_type == "TYPE"
693        ));
694        for value in [
695            "label=type:container_t",
696            "label:type=container_t",
697            "label=type=container_t",
698            "Label:type:container_t",
699            "label:Type:container_t",
700            " label:type:container_t",
701            "label : type : container_t",
702            "label:type:container t",
703            "label:type:",
704            "label:type",
705            "label=type",
706            "type",
707            "label:type:container_t:extended",
708            "label:type:container_t=extended",
709        ] {
710            assert_eq!(
711                classify_security_option(value),
712                SecurityOptionKind::SecurityLabelTypeNearMiss
713            );
714        }
715        for value in [
716            "label:disable",
717            "label:filetype:container_file_t",
718            "label:level:s0:c1,c2",
719            "label:nested",
720            "label:user:USER",
721            "label:role:ROLE",
722        ] {
723            assert!(!matches!(
724                classify_security_option(value),
725                SecurityOptionKind::SecurityLabelType { .. } | SecurityOptionKind::SecurityLabelTypeNearMiss
726            ));
727        }
728        assert_eq!(
729            classify_security_option("label:type:${LABEL_TYPE}"),
730            SecurityOptionKind::Expression
731        );
732    }
733}