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        if prefix.eq_ignore_ascii_case("seccomp")
197            && (suffix.is_empty() || suffix.starts_with(':') || suffix.starts_with('='))
198        {
199            return SecurityOptionKind::SeccompNearMiss;
200        }
201    }
202    if let Some(kind) = classify_exact_boolean_option(value) {
203        return kind;
204    }
205    if let (Some(prefix), Some(suffix)) = (compact.get(..17), compact.get(17..)) {
206        if prefix.eq_ignore_ascii_case("no-new-privileges")
207            && (suffix.is_empty() || suffix.starts_with(':') || suffix.starts_with('='))
208        {
209            return SecurityOptionKind::NoNewPrivilegesNearMiss;
210        }
211    }
212    if let Some(paths) = value.strip_prefix("mask=") {
213        return if !paths.is_empty() && !paths.chars().any(char::is_whitespace) {
214            SecurityOptionKind::Mask {
215                paths: paths.to_owned(),
216            }
217        } else {
218            SecurityOptionKind::MaskNearMiss
219        };
220    }
221    if is_mask_near_miss(value, &compact) {
222        return SecurityOptionKind::MaskNearMiss;
223    }
224    if let Some(paths) = value.strip_prefix("unmask=") {
225        return if valid_unmask_payload(paths) && !value.chars().any(char::is_whitespace) {
226            SecurityOptionKind::Unmask {
227                paths: paths.to_owned(),
228            }
229        } else {
230            SecurityOptionKind::UnmaskNearMiss
231        };
232    }
233    if is_unmask_near_miss(value, &compact) {
234        return SecurityOptionKind::UnmaskNearMiss;
235    }
236    classify_security_label_option(value, &compact).unwrap_or(SecurityOptionKind::Other)
237}
238
239fn valid_unmask_payload(paths: &str) -> bool {
240    paths == "ALL"
241        || paths
242            .split(':')
243            .all(|segment| !segment.is_empty() && segment.starts_with('/'))
244}
245
246fn is_mask_near_miss(value: &str, compact: &str) -> bool {
247    let Some(prefix) = compact.get(..4) else {
248        return false;
249    };
250    if !prefix.eq_ignore_ascii_case("mask") {
251        return false;
252    }
253    let suffix = &compact[4..];
254    suffix.is_empty()
255        || suffix.starts_with(['=', ':'])
256        || suffix
257            .chars()
258            .next()
259            .is_some_and(|delimiter| !delimiter.is_ascii_alphanumeric() && delimiter != '_')
260        || value
261            .trim_start()
262            .get(4..)
263            .and_then(|suffix| suffix.chars().next())
264            .is_some_and(char::is_whitespace)
265}
266
267fn is_unmask_near_miss(value: &str, compact: &str) -> bool {
268    let Some(prefix) = compact.get(..6) else {
269        return false;
270    };
271    if !prefix.eq_ignore_ascii_case("unmask") {
272        return false;
273    }
274    let suffix = &compact[6..];
275    suffix.is_empty()
276        || suffix.starts_with(['=', ':'])
277        || suffix
278            .chars()
279            .next()
280            .is_some_and(|delimiter| !delimiter.is_ascii_alphanumeric() && delimiter != '_')
281        || value
282            .trim_start()
283            .get(6..)
284            .and_then(|suffix| suffix.chars().next())
285            .is_some_and(char::is_whitespace)
286}
287
288fn classify_security_label_option(value: &str, compact: &str) -> Option<SecurityOptionKind> {
289    if let Some(file_type) = value.strip_prefix("label:filetype:") {
290        return Some(if !file_type.is_empty() && !value.chars().any(char::is_whitespace) {
291            SecurityOptionKind::SecurityLabelFileType {
292                file_type: file_type.to_owned(),
293            }
294        } else {
295            SecurityOptionKind::SecurityLabelFileTypeNearMiss
296        });
297    }
298    if let Some(level) = value.strip_prefix("label:level:") {
299        return Some(if !level.is_empty() && !value.chars().any(char::is_whitespace) {
300            SecurityOptionKind::SecurityLabelLevel {
301                level: level.to_owned(),
302            }
303        } else {
304            SecurityOptionKind::SecurityLabelLevelNearMiss
305        });
306    }
307    if let Some(label_type) = value.strip_prefix("label:type:") {
308        return Some(
309            if !label_type.is_empty() && !value.chars().any(char::is_whitespace) && !label_type.contains([':', '=']) {
310                SecurityOptionKind::SecurityLabelType {
311                    label_type: label_type.to_owned(),
312                }
313            } else {
314                SecurityOptionKind::SecurityLabelTypeNearMiss
315            },
316        );
317    }
318    if compact.eq_ignore_ascii_case("label")
319        || compact.eq_ignore_ascii_case("label=disable")
320        || compact.eq_ignore_ascii_case("label:disable")
321        || compact
322            .get(..14)
323            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("label:disable:"))
324    {
325        return Some(SecurityOptionKind::SecurityLabelDisableNearMiss);
326    }
327    let lowercase_compact = compact.to_ascii_lowercase();
328    if lowercase_compact == "label:filetype"
329        || lowercase_compact.starts_with("label:filetype:")
330        || lowercase_compact.starts_with("label:filetype=")
331        || lowercase_compact.starts_with("label=filetype:")
332        || lowercase_compact.starts_with("label=filetype=")
333    {
334        return Some(SecurityOptionKind::SecurityLabelFileTypeNearMiss);
335    }
336    if lowercase_compact == "label:level"
337        || lowercase_compact == "label=level"
338        || lowercase_compact.starts_with("label:level:")
339        || lowercase_compact.starts_with("label:level=")
340        || lowercase_compact.starts_with("label=level:")
341        || lowercase_compact.starts_with("label=level=")
342    {
343        return Some(SecurityOptionKind::SecurityLabelLevelNearMiss);
344    }
345    if is_security_label_nested_near_miss(value, &lowercase_compact) {
346        return Some(SecurityOptionKind::SecurityLabelNestedNearMiss);
347    }
348    if is_security_label_type_near_miss(&lowercase_compact) {
349        return Some(SecurityOptionKind::SecurityLabelTypeNearMiss);
350    }
351    None
352}
353
354fn classify_exact_boolean_option(value: &str) -> Option<SecurityOptionKind> {
355    match value {
356        "no-new-privileges:true" => Some(SecurityOptionKind::NoNewPrivileges { enabled: true }),
357        "no-new-privileges:false" => Some(SecurityOptionKind::NoNewPrivileges { enabled: false }),
358        "label:disable" => Some(SecurityOptionKind::SecurityLabelDisable { enabled: true }),
359        "label:nested" => Some(SecurityOptionKind::SecurityLabelNested { enabled: true }),
360        _ => None,
361    }
362}
363
364fn is_security_label_nested_near_miss(value: &str, lowercase_compact: &str) -> bool {
365    lowercase_compact == "nested"
366        || lowercase_compact == "label=nested"
367        || lowercase_compact.starts_with("label:nested:")
368        || lowercase_compact.starts_with("label:nested=")
369        || lowercase_compact.starts_with("label=nested:")
370        || lowercase_compact.starts_with("label=nested=")
371        || (lowercase_compact == "label:nested" && value != "label:nested")
372}
373
374fn is_security_label_type_near_miss(lowercase_compact: &str) -> bool {
375    lowercase_compact == "type"
376        || lowercase_compact == "label:type"
377        || lowercase_compact == "label=type"
378        || lowercase_compact.starts_with("label:type:")
379        || lowercase_compact.starts_with("label:type=")
380        || lowercase_compact.starts_with("label=type:")
381        || lowercase_compact.starts_with("label=type=")
382}
383
384#[cfg(test)]
385mod tests {
386    use super::{SecurityOptionKind, classify_security_option};
387
388    #[test]
389    fn classifies_only_exact_lowercase_whitespace_free_security_option_candidates() {
390        assert!(matches!(
391            classify_security_option("apparmor=profile-a"),
392            SecurityOptionKind::AppArmor { profile } if profile == "profile-a"
393        ));
394        assert_eq!(
395            classify_security_option("${SECURITY_OPT}"),
396            SecurityOptionKind::Expression
397        );
398        assert_eq!(classify_security_option(""), SecurityOptionKind::Empty);
399        for (value, expected) in [
400            ("seccomp=unconfined", "unconfined"),
401            ("seccomp=/workspace/seccomp.json", "/workspace/seccomp.json"),
402        ] {
403            assert!(matches!(
404                classify_security_option(value),
405                SecurityOptionKind::Seccomp { profile } if profile == expected
406            ));
407        }
408        assert_eq!(
409            classify_security_option("seccomp=${SECCOMP_PROFILE}"),
410            SecurityOptionKind::Expression
411        );
412        for value in [
413            "seccomp",
414            "seccomp:",
415            "seccomp:unconfined",
416            "seccomp=",
417            "Seccomp=unconfined",
418            " seccomp=unconfined",
419            "seccomp =unconfined",
420            "seccomp=profile name",
421        ] {
422            assert_eq!(classify_security_option(value), SecurityOptionKind::SeccompNearMiss);
423        }
424        assert_eq!(
425            classify_security_option("seccomp-extra=true"),
426            SecurityOptionKind::Other
427        );
428        for value in [
429            "apparmor=",
430            "apparmor=profile name",
431            "AppArmor=profile-a",
432            "apparmor:profile-a",
433            " apparmor=profile-a",
434        ] {
435            assert_eq!(classify_security_option(value), SecurityOptionKind::AppArmorNearMiss);
436        }
437        assert_eq!(
438            classify_security_option("no-new-privileges:true"),
439            SecurityOptionKind::NoNewPrivileges { enabled: true }
440        );
441        assert_eq!(
442            classify_security_option("no-new-privileges:false"),
443            SecurityOptionKind::NoNewPrivileges { enabled: false }
444        );
445        for value in [
446            "no-new-privileges",
447            "no-new-privileges=true",
448            "No-New-Privileges:true",
449            " no-new-privileges:true",
450            "no-new-privileges: true",
451        ] {
452            assert_eq!(
453                classify_security_option(value),
454                SecurityOptionKind::NoNewPrivilegesNearMiss
455            );
456        }
457        assert_eq!(
458            classify_security_option("no-new-privileges-extra:true"),
459            SecurityOptionKind::Other
460        );
461        assert_eq!(
462            classify_security_option("label:disable"),
463            SecurityOptionKind::SecurityLabelDisable { enabled: true }
464        );
465        for value in [
466            "label=disable",
467            "label:disable:false",
468            "Label:disable",
469            " label:disable",
470            "label : disable",
471            "label",
472        ] {
473            assert_eq!(
474                classify_security_option(value),
475                SecurityOptionKind::SecurityLabelDisableNearMiss
476            );
477        }
478        for value in ["label:user:USER", "label:role:ROLE"] {
479            assert_eq!(classify_security_option(value), SecurityOptionKind::Other);
480        }
481        assert!(matches!(
482            classify_security_option("label:type:TYPE"),
483            SecurityOptionKind::SecurityLabelType { label_type } if label_type == "TYPE"
484        ));
485        assert_eq!(
486            classify_security_option("label:${LABEL_MODE}"),
487            SecurityOptionKind::Expression
488        );
489    }
490
491    #[test]
492    fn classifies_repeatable_mask_candidates_without_interpreting_payload_paths() {
493        for (value, expected) in [
494            ("mask=/run/secrets", "/run/secrets"),
495            ("mask=/proc/acpi:/proc/kcore", "/proc/acpi:/proc/kcore"),
496            ("mask=relative:opaque=value", "relative:opaque=value"),
497        ] {
498            assert!(matches!(
499                classify_security_option(value),
500                SecurityOptionKind::Mask { paths } if paths == expected
501            ));
502        }
503        for value in [
504            "mask",
505            "mask=",
506            "mask:/run/secrets",
507            "Mask=/run/secrets",
508            "MASK=/run/secrets",
509            " mask=/run/secrets",
510            "mask =/run/secrets",
511            "mask=/run/secret path",
512            "mask-/run/secrets",
513        ] {
514            assert_eq!(classify_security_option(value), SecurityOptionKind::MaskNearMiss);
515        }
516        assert_eq!(classify_security_option("masking=true"), SecurityOptionKind::Other);
517        assert_eq!(classify_security_option("masking true"), SecurityOptionKind::Other);
518        assert_eq!(
519            classify_security_option("mask=${MASK_PATHS}"),
520            SecurityOptionKind::Expression
521        );
522    }
523
524    #[test]
525    fn classifies_only_exact_repeatable_unmask_candidates() {
526        for (value, expected) in [
527            ("unmask=ALL", "ALL"),
528            ("unmask=/proc/acpi", "/proc/acpi"),
529            ("unmask=/proc/acpi:/sys/firmware", "/proc/acpi:/sys/firmware"),
530            ("unmask=/proc/*", "/proc/*"),
531        ] {
532            assert!(matches!(
533                classify_security_option(value),
534                SecurityOptionKind::Unmask { paths } if paths == expected
535            ));
536        }
537        for value in [
538            "unmask",
539            "unmask=",
540            "unmask=all",
541            "Unmask=ALL",
542            "UNMASK=/proc/acpi",
543            "unmask:/proc/acpi",
544            " unmask=/proc/acpi",
545            "unmask=/proc/acpi ",
546            "unmask =/proc/acpi",
547            "unmask=proc/acpi",
548            "unmask=/proc/acpi:relative",
549            "unmask=/proc/acpi:",
550            "unmask=:/proc/acpi",
551            "unmask=/proc/acpi::/sys/firmware",
552            "unmask=ALL:/proc/acpi",
553            "unmask=/proc/acpi:ALL",
554            "unmask-/proc/acpi",
555        ] {
556            assert_eq!(classify_security_option(value), SecurityOptionKind::UnmaskNearMiss);
557        }
558        assert_eq!(classify_security_option("unmasking=true"), SecurityOptionKind::Other);
559        assert_eq!(
560            classify_security_option("unmask=${UNMASK_PATHS}"),
561            SecurityOptionKind::Expression
562        );
563    }
564
565    #[test]
566    fn classifies_only_exact_label_filetype_candidates_and_precise_near_misses() {
567        assert!(matches!(
568            classify_security_option("label:filetype:container_file_t"),
569            SecurityOptionKind::SecurityLabelFileType { file_type }
570                if file_type == "container_file_t"
571        ));
572        for value in [
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:container_file_t",
578            "label:filetype:container file t",
579            "label:filetype:",
580            "label:filetype",
581        ] {
582            assert_eq!(
583                classify_security_option(value),
584                SecurityOptionKind::SecurityLabelFileTypeNearMiss
585            );
586        }
587        for value in ["label:user:USER", "label:role:ROLE"] {
588            assert_eq!(classify_security_option(value), SecurityOptionKind::Other);
589        }
590        assert!(matches!(
591            classify_security_option("label:type:TYPE"),
592            SecurityOptionKind::SecurityLabelType { label_type } if label_type == "TYPE"
593        ));
594        assert!(matches!(
595            classify_security_option("label:level:LEVEL"),
596            SecurityOptionKind::SecurityLabelLevel { level } if level == "LEVEL"
597        ));
598        assert_eq!(
599            classify_security_option("label:filetype:${LABEL_TYPE}"),
600            SecurityOptionKind::Expression
601        );
602    }
603
604    #[test]
605    fn classifies_only_exact_label_level_candidates_and_precise_near_misses() {
606        assert!(matches!(
607            classify_security_option("label:level:s0:c1,c2"),
608            SecurityOptionKind::SecurityLabelLevel { level }
609                if level == "s0:c1,c2"
610        ));
611        for value in [
612            "label=level:s0:c1,c2",
613            "label:level=s0:c1,c2",
614            "Label:level:s0:c1,c2",
615            "label:Level:s0:c1,c2",
616            " label:level:s0:c1,c2",
617            "label:level:s0 c1",
618            "label:level:",
619            "label:level",
620            "label=level",
621        ] {
622            assert_eq!(
623                classify_security_option(value),
624                SecurityOptionKind::SecurityLabelLevelNearMiss
625            );
626        }
627        for value in [
628            "label:type:TYPE",
629            "label:user:USER",
630            "label:role:ROLE",
631            "label:filetype:container_file_t",
632            "label:disable",
633        ] {
634            assert!(!matches!(
635                classify_security_option(value),
636                SecurityOptionKind::SecurityLabelLevel { .. } | SecurityOptionKind::SecurityLabelLevelNearMiss
637            ));
638        }
639        assert_eq!(
640            classify_security_option("label:level:${LABEL_LEVEL}"),
641            SecurityOptionKind::Expression
642        );
643    }
644
645    #[test]
646    fn classifies_only_exact_label_nested_candidates_and_precise_near_misses() {
647        assert_eq!(
648            classify_security_option("label:nested"),
649            SecurityOptionKind::SecurityLabelNested { enabled: true }
650        );
651        for value in [
652            "label=nested",
653            "Label:nested",
654            "label:Nested",
655            " label:nested",
656            "label : nested",
657            "label:nested:true",
658            "label:nested=",
659            "nested",
660        ] {
661            assert_eq!(
662                classify_security_option(value),
663                SecurityOptionKind::SecurityLabelNestedNearMiss
664            );
665        }
666        for value in [
667            "label:disable",
668            "label:filetype:container_file_t",
669            "label:level:s0:c1,c2",
670            "label:type:TYPE",
671            "label:user:USER",
672            "label:role:ROLE",
673        ] {
674            assert!(!matches!(
675                classify_security_option(value),
676                SecurityOptionKind::SecurityLabelNested { .. } | SecurityOptionKind::SecurityLabelNestedNearMiss
677            ));
678        }
679        assert_eq!(
680            classify_security_option("${LABEL_NESTED_OPTION}"),
681            SecurityOptionKind::Expression
682        );
683    }
684
685    #[test]
686    fn classifies_only_exact_label_type_candidates_and_precise_near_misses() {
687        assert!(matches!(
688            classify_security_option("label:type:container_t"),
689            SecurityOptionKind::SecurityLabelType { label_type }
690                if label_type == "container_t"
691        ));
692        assert!(matches!(
693            classify_security_option("label:type:TYPE"),
694            SecurityOptionKind::SecurityLabelType { label_type } if label_type == "TYPE"
695        ));
696        for value in [
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 : container_t",
704            "label:type:container t",
705            "label:type:",
706            "label:type",
707            "label=type",
708            "type",
709            "label:type:container_t:extended",
710            "label:type:container_t=extended",
711        ] {
712            assert_eq!(
713                classify_security_option(value),
714                SecurityOptionKind::SecurityLabelTypeNearMiss
715            );
716        }
717        for value in [
718            "label:disable",
719            "label:filetype:container_file_t",
720            "label:level:s0:c1,c2",
721            "label:nested",
722            "label:user:USER",
723            "label:role:ROLE",
724        ] {
725            assert!(!matches!(
726                classify_security_option(value),
727                SecurityOptionKind::SecurityLabelType { .. } | SecurityOptionKind::SecurityLabelTypeNearMiss
728            ));
729        }
730        assert_eq!(
731            classify_security_option("label:type:${LABEL_TYPE}"),
732            SecurityOptionKind::Expression
733        );
734    }
735}