Skip to main content

ignition_core/deploy/
exclude.rs

1//! Excluded tag providers: gateway-owned providers no deploy verb touches.
2//!
3//! Some tag providers belong to the gateway, not to the declared tree: the
4//! built-in `System` provider, and providers a module generates (Cirrus Link's
5//! `MQTT Engine` builds its tags from Sparkplug births). An excluded
6//! provider's four [`TAG_TYPES`] subtrees (`ignition/<tag type>/<P>/`) are
7//! never listed, read, written or deleted by `deploy pull`, `plan`, `verify`
8//! or `apply`.
9//!
10//! - **The effective set** is the union of [`DEFAULT_EXCLUDED_TAG_PROVIDERS`]
11//!   (`System`), the target's `exclude_tag_providers` and the manifest's
12//!   `exclude_tag_providers` ([`effective`]). The `System` default cannot be
13//!   turned off in this release: no target or manifest field removes it.
14//! - **Matching folds ASCII case** ([`is_excluded`]). Ignition provider names
15//!   and NTFS paths are case-insensitive, so a declared `system` is the
16//!   excluded `System`. This is an assumption about the gateway, stated here
17//!   so a change to it is a visible decision.
18//! - **Contradictions are refused.** A provider that is excluded and also
19//!   declared (manifest `tag_providers`, or a `tags/<P>/` tree) is a
20//!   `deploy_tree_invalid` finding ([`contradictions`], and render's policy
21//!   stage).
22//! - **Never deleted.** A ledger file under an excluded provider never enters
23//!   plan's delete set, even from a ledger written before the exclusion
24//!   ([`excluded_provider`]).
25//! - **Not hashed.** The effective set is not part of `plan_sha256`; it is
26//!   reported in the additive `excluded_tag_providers` data key of the four
27//!   verbs and on one human-mode stderr line.
28
29use std::collections::BTreeMap;
30
31use super::component_problem;
32use super::layout::{RelPath, TAG_TYPES};
33use super::manifest::Manifest;
34use super::target::Target;
35use crate::error::DeployFinding;
36
37/// The providers excluded without being listed. Always part of the effective
38/// set; this release has no way to turn the default off.
39pub const DEFAULT_EXCLUDED_TAG_PROVIDERS: [&str; 1] = ["System"];
40
41/// Why `name` cannot be an `exclude_tag_providers` entry, or `None`.
42///
43/// [`component_problem`], plus a blank entry, an entry with leading or
44/// trailing whitespace (a padded name would silently match no provider) and
45/// an entry holding a control character anywhere (a newline or ESC would
46/// forge or garble the stderr line that names the excluded providers).
47pub fn entry_problem(name: &str) -> Option<&'static str> {
48    if !name.is_empty() && name.trim().is_empty() {
49        Some("must not be blank")
50    } else if name.trim() != name {
51        Some("must not have leading or trailing whitespace")
52    } else if name.chars().any(char::is_control) {
53        Some("must not contain a control character")
54    } else {
55        component_problem(name)
56    }
57}
58
59/// The findings of an `exclude_tag_providers` list: each bad entry, and each
60/// entry equal to an earlier one under ASCII case folding (duplicates are
61/// refused, not normalized). Messages are `exclude_tag_providers[i] "X": why`.
62pub(crate) fn list_problems(list: &[String]) -> Vec<String> {
63    let mut problems = Vec::new();
64    for (i, name) in list.iter().enumerate() {
65        if let Some(why) = entry_problem(name) {
66            problems.push(format!("exclude_tag_providers[{i}] {name:?}: {why}"));
67        }
68        if list[..i]
69            .iter()
70            .any(|earlier| earlier.eq_ignore_ascii_case(name))
71        {
72            problems.push(format!(
73                "exclude_tag_providers[{i}] {name:?}: duplicate tag provider"
74            ));
75        }
76    }
77    problems
78}
79
80/// The effective exclusion set: the built-in default, then the target's
81/// list, then the manifest's (absent before a pull has a manifest).
82/// Deduplicated by ASCII case fold, keeping the byte-smallest spelling;
83/// byte-sorted. Always holds `System`.
84pub fn effective(target: &Target, manifest: Option<&Manifest>) -> Vec<String> {
85    let mut by_fold: BTreeMap<String, String> = BTreeMap::new();
86    let listed = DEFAULT_EXCLUDED_TAG_PROVIDERS
87        .iter()
88        .map(|p| (*p).to_string())
89        .chain(target.exclude_tag_providers.iter().cloned())
90        .chain(
91            manifest
92                .into_iter()
93                .flat_map(|m| m.exclude_tag_providers.iter().cloned()),
94        );
95    for name in listed {
96        let fold = name.to_ascii_lowercase();
97        match by_fold.get_mut(&fold) {
98            Some(kept) if name.as_bytes() < kept.as_bytes() => *kept = name,
99            Some(_) => {}
100            None => {
101                by_fold.insert(fold, name);
102            }
103        }
104    }
105    let mut names: Vec<String> = by_fold.into_values().collect();
106    names.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
107    names
108}
109
110/// Whether `provider` is in `excluded`, under ASCII case folding.
111pub fn is_excluded(provider: &str, excluded: &[String]) -> bool {
112    excluded.iter().any(|e| e.eq_ignore_ascii_case(provider))
113}
114
115/// The provider component of a collection-relative `path` under one of the
116/// four tag type directories of an excluded provider
117/// (`ignition/<tag type>/<P>/...` with `P` excluded), spelled as in `path`;
118/// `None` for any other path.
119pub fn excluded_provider<'a>(path: &'a RelPath, excluded: &[String]) -> Option<&'a str> {
120    let mut parts = path.components();
121    let (module, ty, provider) = (parts.next()?, parts.next()?, parts.next()?);
122    (module == "ignition" && TAG_TYPES.contains(&ty) && is_excluded(provider, excluded))
123        .then_some(provider)
124}
125
126/// One finding per `manifest.tag_providers[i]` that is excluded, labelled
127/// `manifest_file`, with no line or column.
128pub fn contradictions(
129    excluded: &[String],
130    manifest: &Manifest,
131    manifest_file: &str,
132) -> Vec<DeployFinding> {
133    manifest
134        .tag_providers
135        .iter()
136        .enumerate()
137        .filter(|(_, provider)| is_excluded(provider, excluded))
138        .map(|(i, provider)| DeployFinding {
139            file: manifest_file.to_string(),
140            line: None,
141            column: None,
142            message: format!(
143                "tag_providers[{i}] {provider:?}: the tag provider is excluded (by exclude_tag_providers or the built-in System default); an excluded provider cannot be declared"
144            ),
145        })
146        .collect()
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn target(extra: &str) -> Target {
154        let yaml = format!(
155            "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n  master:\n    profile: dev\n{extra}"
156        );
157        Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("target")
158    }
159
160    fn manifest(extra: &str) -> Manifest {
161        let yaml = format!("schema_version: 1\ntypes: []\n{extra}");
162        Manifest::parse("gateways/g/manifest.yaml", yaml.as_bytes()).expect("manifest")
163    }
164
165    #[test]
166    fn exclude_default_is_system_alone() {
167        assert_eq!(DEFAULT_EXCLUDED_TAG_PROVIDERS, ["System"]);
168        assert_eq!(effective(&target(""), None), ["System"]);
169        assert_eq!(effective(&target(""), Some(&manifest(""))), ["System"]);
170    }
171
172    #[test]
173    fn exclude_effective_unions_default_target_and_manifest() {
174        let t = target("exclude_tag_providers: [\"MQTT Engine\"]\n");
175        let m = manifest("exclude_tag_providers: [\"Sparkplug B\", system]\n");
176        // fold dedupe keeps the byte-smallest spelling: `System` < `system`
177        assert_eq!(
178            effective(&t, Some(&m)),
179            ["MQTT Engine", "Sparkplug B", "System"]
180        );
181        // overlap across sources is unioned, not refused
182        let both = manifest("exclude_tag_providers: [\"MQTT Engine\"]\n");
183        assert_eq!(effective(&t, Some(&both)), ["MQTT Engine", "System"]);
184    }
185
186    #[test]
187    fn exclude_is_excluded_folds_ascii_case() {
188        let excluded = vec!["MQTT Engine".to_string(), "System".to_string()];
189        assert!(is_excluded("system", &excluded));
190        assert!(is_excluded("mqtt ENGINE", &excluded));
191        assert!(!is_excluded("default", &excluded));
192        assert!(!is_excluded("MQTT", &excluded));
193    }
194
195    #[test]
196    fn exclude_contradictions_flag_declared_excluded_providers() {
197        let m = manifest("tag_providers: [default, system]\n");
198        let found = contradictions(
199            &effective(&target(""), Some(&m)),
200            &m,
201            "gateways/g/manifest.yaml",
202        );
203        assert_eq!(found.len(), 1, "{found:?}");
204        assert_eq!(found[0].file, "gateways/g/manifest.yaml");
205        assert_eq!((found[0].line, found[0].column), (None, None));
206        assert!(
207            found[0].message.starts_with("tag_providers[1] \"system\"")
208                && found[0].message.contains("excluded"),
209            "{}",
210            found[0].message
211        );
212
213        let t = target("exclude_tag_providers: [\"MQTT Engine\"]\n");
214        let m = manifest("tag_providers: [\"MQTT Engine\", default]\n");
215        let found = contradictions(&effective(&t, Some(&m)), &m, "gateways/g/manifest.yaml");
216        assert_eq!(found.len(), 1, "{found:?}");
217        assert!(
218            found[0]
219                .message
220                .starts_with("tag_providers[0] \"MQTT Engine\""),
221            "{}",
222            found[0].message
223        );
224
225        let m = manifest("tag_providers: [default]\n");
226        assert!(contradictions(&effective(&t, Some(&m)), &m, "x").is_empty());
227    }
228
229    #[test]
230    fn exclude_excluded_provider_matches_the_four_tag_types_only() {
231        let excluded = vec!["MQTT Engine".to_string(), "System".to_string()];
232        let of = |p: &str| {
233            let path = RelPath::new(p).expect("path");
234            excluded_provider(&path, &excluded).map(str::to_string)
235        };
236        for (path, want) in [
237            (
238                "ignition/tag-provider/MQTT Engine/config.json",
239                Some("MQTT Engine"),
240            ),
241            (
242                "ignition/tag-group/System/Default/config.json",
243                Some("System"),
244            ),
245            (
246                "ignition/tag-type-definition/system/T/tags.json",
247                Some("system"),
248            ),
249            (
250                "ignition/tag-definition/mqtt engine/Edge Nodes/tags.json",
251                Some("mqtt engine"),
252            ),
253            ("ignition/tag-provider/default/config.json", None),
254            ("ignition/database-translator/MQTT Engine/config.json", None),
255            ("com.example/tag-provider/MQTT Engine/config.json", None),
256        ] {
257            assert_eq!(of(path).as_deref(), want, "{path}");
258        }
259    }
260
261    #[test]
262    fn exclude_entry_problem_refuses_blank_padded_and_unsafe_names() {
263        assert_eq!(entry_problem("MQTT Engine"), None);
264        assert_eq!(entry_problem("System"), None);
265        for bad in [
266            "",
267            "   ",
268            " MQTT Engine",
269            "MQTT Engine\t",
270            "a/b",
271            ".x",
272            "..",
273        ] {
274            assert!(entry_problem(bad).is_some(), "{bad:?}");
275        }
276    }
277
278    /// C3 (PR #31 review): a control character anywhere in an entry is
279    /// refused (a newline or ESC in a provider name would forge or garble
280    /// a stderr line); the blank and padding checks keep their wording.
281    #[test]
282    fn exclude_entry_problem_refuses_control_characters() {
283        for bad in [
284            "MQTT\nEngine",
285            "MQTT\rEngine",
286            "MQTT\u{1b}[31mEngine",
287            "a\u{7f}b",
288            "a\u{85}b",
289            "a\u{0}b",
290        ] {
291            assert_eq!(
292                entry_problem(bad),
293                Some("must not contain a control character"),
294                "{bad:?}"
295            );
296        }
297        assert_eq!(entry_problem("\n"), Some("must not be blank"));
298        assert_eq!(
299            entry_problem("MQTT Engine\n"),
300            Some("must not have leading or trailing whitespace")
301        );
302        assert_eq!(entry_problem("MQTT Engine (caf\u{e9})"), None);
303    }
304}