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