Skip to main content

ignition_core/deploy/
layout.rs

1//! Path vocabulary for declared trees: [`RelPath`], collection paths and
2//! resource keys.
3//!
4//! A *repo-relative* path is `gateways/<g>/resources/<module>/<type>/...`;
5//! its *collection path* drops the `resources/` prefix and is what the
6//! gateway holds under its resource collection directory
7//! (`<module>/<type>/...`). All paths are `/`-separated UTF-8 and compare as
8//! raw bytes: no normalization, no case folding (DRND-01 encoding edge).
9
10/// A validated `/`-separated relative path.
11///
12/// The constructor is the only way to build one, so every `RelPath` is free
13/// of a leading `/`, backslashes, NUL bytes and empty, `.` or `..`
14/// components. `Ord` is the inner `String`'s byte order.
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
16pub struct RelPath(String);
17
18impl RelPath {
19    /// Validate `s` as a relative path.
20    pub fn new(s: &str) -> Result<RelPath, String> {
21        if s.is_empty() {
22            return Err("path must not be empty".to_string());
23        }
24        if s.starts_with('/') {
25            return Err("path must not start with `/`".to_string());
26        }
27        if s.contains('\\') {
28            return Err("path must not contain a backslash".to_string());
29        }
30        if s.contains('\0') {
31            return Err("path must not contain a NUL byte".to_string());
32        }
33        for component in s.split('/') {
34            match component {
35                "" => return Err("path must not contain an empty component".to_string()),
36                "." | ".." => return Err("path must not contain `.` or `..`".to_string()),
37                _ => {}
38            }
39        }
40        Ok(RelPath(s.to_string()))
41    }
42
43    /// The path as a `/`-joined string.
44    pub fn as_str(&self) -> &str {
45        &self.0
46    }
47
48    /// The `/`-separated components.
49    pub fn components(&self) -> std::str::Split<'_, char> {
50        self.0.split('/')
51    }
52
53    /// The remainder after `prefix` plus one `/`, when this path starts with
54    /// that whole component prefix and has something left.
55    pub fn strip_prefix(&self, prefix: &str) -> Option<RelPath> {
56        let rest = self.0.strip_prefix(prefix)?.strip_prefix('/')?;
57        RelPath::new(rest).ok()
58    }
59}
60
61impl std::fmt::Display for RelPath {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.write_str(&self.0)
64    }
65}
66
67/// The identity of one gateway resource.
68#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
69pub struct ResourceKey {
70    /// Module id, e.g. `ignition`.
71    pub module: String,
72    /// Resource type, e.g. `database-translator`.
73    pub ty: String,
74    /// Resource name; `None` for a singleton. Orders before any name.
75    pub name: Option<String>,
76}
77
78/// The four tag types the gateway writes under `ignition/` for a tag
79/// provider, in the order a provider is built (21-02 capture, decision CD5).
80pub const TAG_TYPES: [&str; 4] = [
81    "tag-provider",
82    "tag-group",
83    "tag-type-definition",
84    "tag-definition",
85];
86
87/// The two tag types a redundant pair cannot carry in a **mode** collection
88/// (debug deploy-tag-redundancy-sync, captured on an 8.3.6 pair).
89///
90/// Ignition's `config-resource` synchronizer counts every resource of a mode
91/// collection in its snapshot digest but builds each diff with a filter that
92/// leaves these two types out, and `tag-config-resource` syncs `core` only.
93/// A mode-collection path of either type on the master, even the empty type
94/// folder `ignition/<type>`, therefore makes the backup's digest unreachable:
95/// the backup fails its full pulls and stops syncing configuration. The same
96/// files in `core` sync normally, and so do `tag-provider` and `tag-group`
97/// units in a mode collection.
98pub const PAIR_UNSYNCED_TAG_TYPES: [&str; 2] = ["tag-type-definition", "tag-definition"];
99
100/// Whether the collection path `path` is the type folder of one of the
101/// [`PAIR_UNSYNCED_TAG_TYPES`] (`ignition/tag-definition`) or below it.
102/// Exact bytes, as every collection path compares.
103pub fn is_pair_unsynced_tag_path(path: &str) -> bool {
104    let mut parts = path.split('/');
105    parts.next() == Some("ignition")
106        && parts
107            .next()
108            .is_some_and(|ty| PAIR_UNSYNCED_TAG_TYPES.contains(&ty))
109}
110
111/// Gateway-relative path to collection path.
112///
113/// Two layouts are accepted:
114///
115/// - `resources/<m>/<t>/...` to `<m>/<t>/...`. Refuses a path with fewer than
116///   three remaining components (a file directly under `resources/` or
117///   `resources/<module>/` is not a resource file).
118/// - `tags/<P>/<type>/<rest>` to `ignition/<type>/<P>/<rest>` for the four
119///   [`TAG_TYPES`]. This is decision **CD5** of `21-LIVE-CAPTURES.md`,
120///   captured on an 8.3.6 gateway: the type and provider segments swap
121///   (confirming RESEARCH assumption A5, so CD5 and the research guess
122///   agree). Folders nest as directories; `tag-group` entries are named
123///   resources (`<P>/<Group>/{config,resource}.json`) while
124///   `tag-type-definition` and `tag-definition` carry `udts.json`,
125///   `tags.json` and `unary-resource.json`.
126///
127/// Two layouts CD5 left unexercised were captured later on an 8.3.6 Docker
128/// rig and the Windows VM (`261030-8m8-LIVE-CAPTURES.md`) and are mapped
129/// read-write like folder tags:
130///
131/// - **CD5a**, root-level tags: `tags/<P>/tag-definition/tags.json` (and
132///   `udts.json`) to `ignition/tag-definition/<P>/tags.json`. The gateway
133///   writes them beside the provider-root `unary-resource.json`, so they
134///   join the provider-root unit `ignition/tag-definition/<P>`.
135/// - **CD5b**, nested type folders: `tags/<P>/tag-type-definition/A/B/...`
136///   to `ignition/tag-type-definition/<P>/A/B/...`. A type in `_types_/A/B`
137///   lands in `A/B/` (no `_types_` directory exists); every folder carries
138///   its own marker, a folder holding only sub-folders with `files: []`, so
139///   each folder is its own unit.
140///
141/// One write, change or delete of either layout takes effect after one
142/// config scan, as for folder tags (CD5-CTL). Render checks each marker's
143/// `files` against its member files (CD5c) and refuses a folder named like a
144/// member file (CD5d); both live in render, not here. The one layout still
145/// unexercised is a collection other than the default one, which this
146/// mapping cannot express (it has no collection concept).
147pub fn collection_path(repo_rel: &RelPath) -> Result<RelPath, String> {
148    if let Some(rest) = repo_rel.strip_prefix("tags") {
149        return tag_collection_path(&rest);
150    }
151    let rest = repo_rel
152        .strip_prefix("resources")
153        .ok_or_else(|| "not under `resources/` or `tags/`".to_string())?;
154    if rest.components().count() < 3 {
155        return Err(
156            "a resource file must sit at `resources/<module>/<type>/<file>` or deeper".to_string(),
157        );
158    }
159    Ok(rest)
160}
161
162/// Collection path to gateway-relative repo path: the exact inverse of
163/// [`collection_path`] (CD5), used by `deploy pull` to place live files.
164///
165/// - `ignition/<tagtype>/<P>/<rest>` with `tagtype` one of [`TAG_TYPES`]
166///   becomes `tags/<P>/<tagtype>/<rest>`;
167/// - any other path with at least three components becomes
168///   `resources/<collection>`.
169///
170/// Fail-closed: a tag type with no provider or no file below it, an
171/// `ignition/tag-*` type that is not one of the four tag types, and a path
172/// with fewer than three components are errors. Every path this function
173/// accepts maps back through [`collection_path`] (root-level tags CD5a and
174/// nested type folders CD5b included). Messages carry no names.
175pub fn repo_path(collection: &RelPath) -> Result<RelPath, String> {
176    let parts: Vec<&str> = collection.components().collect();
177    if parts.len() < 3 {
178        return Err(
179            "a resource file must sit at `<module>/<type>/<file>` or deeper in a collection"
180                .to_string(),
181        );
182    }
183    if parts[0] == "ignition" {
184        let ty = parts[1];
185        if TAG_TYPES.contains(&ty) {
186            if parts.len() < 4 {
187                return Err(
188                    "a tag file must sit at `ignition/<tag type>/<provider>/<file>` or deeper"
189                        .to_string(),
190                );
191            }
192            return RelPath::new(&format!("tags/{}/{ty}/{}", parts[2], parts[3..].join("/")));
193        }
194        if ty.starts_with("tag-") {
195            return Err(format!(
196                "an `ignition/tag-*` type that is not one of the tag types ({})",
197                TAG_TYPES.join(", ")
198            ));
199        }
200    }
201    RelPath::new(&format!("resources/{collection}"))
202}
203
204/// `<P>/<type>/<rest>` (the part after `tags/`) to `ignition/<type>/<P>/<rest>`.
205fn tag_collection_path(rest: &RelPath) -> Result<RelPath, String> {
206    let parts: Vec<&str> = rest.components().collect();
207    if parts.len() < 3 {
208        return Err("a tag file must sit at `tags/<provider>/<type>/<file>` or deeper".to_string());
209    }
210    let (provider, ty, tail) = (parts[0], parts[1], &parts[2..]);
211    if !TAG_TYPES.contains(&ty) {
212        return Err(format!(
213            "`{ty}` is not a tag type (expected one of: {})",
214            TAG_TYPES.join(", ")
215        ));
216    }
217    // Any tail maps: root-level tags (CD5a) and nested type folders (CD5b)
218    // were captured and are accepted (261030-8m8-LIVE-CAPTURES.md).
219    RelPath::new(&format!("ignition/{ty}/{provider}/{}", tail.join("/")))
220}
221
222/// The tag provider directory a gateway-relative `tags/<P>/...` path sits in.
223pub fn tag_provider(repo_rel: &RelPath) -> Option<&str> {
224    let mut parts = repo_rel.components();
225    if parts.next() != Some("tags") {
226        return None;
227    }
228    parts.next()
229}
230
231/// The resource a collection path belongs to: module and type from
232/// components 1 and 2, the name from component 3 only when a 4th exists
233/// (otherwise component 3 is a file of a singleton).
234pub fn resource_key(collection: &RelPath) -> ResourceKey {
235    let parts: Vec<&str> = collection.components().collect();
236    ResourceKey {
237        module: parts.first().copied().unwrap_or_default().to_string(),
238        ty: parts.get(1).copied().unwrap_or_default().to_string(),
239        name: if parts.len() >= 4 {
240            Some(parts[2].to_string())
241        } else {
242            None
243        },
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn rp(s: &str) -> RelPath {
252        RelPath::new(s).unwrap()
253    }
254
255    #[test]
256    fn valid_paths_round_trip() {
257        for ok in ["a", "a/b", "résumé/x", "A/a", "a b/c.json"] {
258            assert_eq!(RelPath::new(ok).unwrap().as_str(), ok);
259        }
260    }
261
262    #[test]
263    fn hostile_paths_are_refused() {
264        for bad in [
265            "", "/abs", "a\\b", "a//b", "a/", "./a", "a/./b", "a/..", "../a", "a\0b",
266        ] {
267            assert!(RelPath::new(bad).is_err(), "{bad:?}");
268        }
269    }
270
271    #[test]
272    fn ordering_is_utf8_byte_order() {
273        let mut v = [rp("b"), rp("a"), rp("B"), rp("é"), rp("Z")];
274        v.sort();
275        let got: Vec<&str> = v.iter().map(RelPath::as_str).collect();
276        assert_eq!(got, ["B", "Z", "a", "b", "é"]);
277    }
278
279    #[test]
280    fn strip_prefix_needs_a_whole_component() {
281        assert_eq!(
282            rp("resources/a/b").strip_prefix("resources"),
283            Some(rp("a/b"))
284        );
285        assert_eq!(rp("resourcesx/a").strip_prefix("resources"), None);
286        assert_eq!(rp("resources").strip_prefix("resources"), None);
287    }
288
289    #[test]
290    fn collection_path_requires_three_components() {
291        assert_eq!(
292            collection_path(&rp("resources/ignition/x/config.json")).unwrap(),
293            rp("ignition/x/config.json")
294        );
295        assert!(collection_path(&rp("resources/config.json")).is_err());
296        assert!(collection_path(&rp("resources/ignition/config.json")).is_err());
297        assert!(collection_path(&rp("other/ignition/x/y")).is_err());
298    }
299
300    #[test]
301    fn tags_map_per_cd5() {
302        let cases = [
303            (
304                "tags/P/tag-provider/config.json",
305                "ignition/tag-provider/P/config.json",
306            ),
307            (
308                "tags/P/tag-group/Default/resource.json",
309                "ignition/tag-group/P/Default/resource.json",
310            ),
311            (
312                "tags/P/tag-type-definition/udts.json",
313                "ignition/tag-type-definition/P/udts.json",
314            ),
315            (
316                "tags/P/tag-definition/unary-resource.json",
317                "ignition/tag-definition/P/unary-resource.json",
318            ),
319            (
320                "tags/P/tag-definition/FolderA/Sub/tags.json",
321                "ignition/tag-definition/P/FolderA/Sub/tags.json",
322            ),
323            // CD5a (261030-8m8): root-level tags join the provider-root unit
324            (
325                "tags/P/tag-definition/tags.json",
326                "ignition/tag-definition/P/tags.json",
327            ),
328            (
329                "tags/P/tag-definition/udts.json",
330                "ignition/tag-definition/P/udts.json",
331            ),
332            // CD5b (261030-8m8): nested type folders, each its own unit
333            (
334                "tags/P/tag-type-definition/A/B/udts.json",
335                "ignition/tag-type-definition/P/A/B/udts.json",
336            ),
337            (
338                "tags/P/tag-type-definition/A/B/unary-resource.json",
339                "ignition/tag-type-definition/P/A/B/unary-resource.json",
340            ),
341            (
342                "tags/P/tag-type-definition/A/unary-resource.json",
343                "ignition/tag-type-definition/P/A/unary-resource.json",
344            ),
345            // `_types_` is never a directory (CD5b); a folder of that name is
346            // a plain nested folder
347            (
348                "tags/P/tag-type-definition/_types_/udts.json",
349                "ignition/tag-type-definition/P/_types_/udts.json",
350            ),
351        ];
352        for (from, to) in cases {
353            assert_eq!(collection_path(&rp(from)).unwrap(), rp(to), "{from}");
354        }
355        assert_eq!(tag_provider(&rp("tags/P/tag-group/x")), Some("P"));
356        assert_eq!(tag_provider(&rp("resources/a/b/c")), None);
357    }
358
359    #[test]
360    fn unmapped_tag_layouts_stay_fail_closed() {
361        // CD5a and CD5b lifted the root-level and nested refusals; what has
362        // no tag type or no file below the type is still refused
363        for bad in [
364            "tags/P/other/x.json",
365            "tags/P/x.json",
366            "tags/x.json",
367            "tags/P/tag-definition",
368        ] {
369            assert!(collection_path(&rp(bad)).is_err(), "{bad}");
370        }
371    }
372
373    #[test]
374    fn repo_path_inverts_collection_path() {
375        // resource and tag collection paths, including nested tag folders
376        let cases = [
377            "ignition/general-alarm-settings/config.json",
378            "ignition/database-translator/POSTGRES/config.json",
379            "ignition/database-translator/POSTGRES/resource.json",
380            "com.example.mod/thing/A B/x/y.json",
381            "ignition/tag-provider/P/config.json",
382            "ignition/tag-group/P/Default/resource.json",
383            "ignition/tag-type-definition/P/udts.json",
384            "ignition/tag-definition/P/unary-resource.json",
385            "ignition/tag-definition/P/FolderA/Sub/tags.json",
386            // CD5a and CD5b (261030-8m8)
387            "ignition/tag-definition/P/tags.json",
388            "ignition/tag-definition/P/udts.json",
389            "ignition/tag-type-definition/P/A/B/udts.json",
390            "ignition/tag-type-definition/P/A/B/unary-resource.json",
391            "ignition/tag-type-definition/P/A/unary-resource.json",
392        ];
393        for collection in cases {
394            let c = rp(collection);
395            let repo = repo_path(&c).unwrap_or_else(|e| panic!("{collection}: {e}"));
396            assert_eq!(
397                collection_path(&repo).unwrap(),
398                c,
399                "{collection} via {repo}"
400            );
401        }
402        assert_eq!(
403            repo_path(&rp("ignition/tag-group/P/Default/resource.json")).unwrap(),
404            rp("tags/P/tag-group/Default/resource.json")
405        );
406        assert_eq!(
407            repo_path(&rp("ignition/database-translator/X/config.json")).unwrap(),
408            rp("resources/ignition/database-translator/X/config.json")
409        );
410    }
411
412    #[test]
413    fn repo_path_refuses_what_has_no_layout() {
414        // too short for a resource file
415        assert!(repo_path(&rp("ignition/config.json")).is_err());
416        assert!(repo_path(&rp("config.json")).is_err());
417        // a tag type with no provider or no file below it
418        assert!(repo_path(&rp("ignition/tag-definition/P")).is_err());
419        assert!(repo_path(&rp("ignition/tag-definition/x.json")).is_err());
420        // a tag-looking type that is not one of the four tag types
421        assert!(repo_path(&rp("ignition/tag-other/P/x.json")).is_err());
422    }
423
424    use proptest::prelude::*;
425
426    proptest! {
427        /// CD5a/CD5b (261030-8m8): for every tag type and any folder chain
428        /// of 1 to 6 segments below the provider, `collection_path` and
429        /// `repo_path` are exact inverses. No tag layout is refused any more,
430        /// so nothing is assumed out.
431        #[test]
432        fn cd5_tag_paths_round_trip(
433            provider in "[A-Za-z0-9_]{1,12}",
434            ty in prop::sample::select(TAG_TYPES.to_vec()),
435            tail in prop::collection::vec("[A-Za-z0-9_]{1,12}", 1..=6),
436        ) {
437            let repo = rp(&format!("tags/{provider}/{ty}/{}", tail.join("/")));
438            let collection = collection_path(&repo).expect("every tag layout maps");
439            prop_assert_eq!(
440                collection.as_str(),
441                format!("ignition/{ty}/{provider}/{}", tail.join("/"))
442            );
443            prop_assert_eq!(&repo_path(&collection).expect("inverse"), &repo);
444            prop_assert_eq!(&collection_path(&repo_path(&collection).unwrap()).unwrap(), &collection);
445        }
446    }
447
448    #[test]
449    fn resource_key_names_only_with_a_fourth_component() {
450        let singleton = resource_key(&rp("ignition/general-alarm-settings/config.json"));
451        assert_eq!(singleton.name, None);
452        let named = resource_key(&rp("ignition/database-translator/POSTGRES/config.json"));
453        assert_eq!(named.module, "ignition");
454        assert_eq!(named.ty, "database-translator");
455        assert_eq!(named.name.as_deref(), Some("POSTGRES"));
456    }
457
458    #[test]
459    fn singleton_key_orders_before_named_of_same_type() {
460        let a = ResourceKey {
461            module: "m".into(),
462            ty: "t".into(),
463            name: None,
464        };
465        let b = ResourceKey {
466            module: "m".into(),
467            ty: "t".into(),
468            name: Some("A".into()),
469        };
470        assert!(a < b);
471    }
472
473    /// debug deploy-tag-redundancy-sync: exactly the two types the pair's
474    /// `config-resource` filter leaves out (T6, C5, C6), from the type folder
475    /// down, by exact bytes.
476    #[test]
477    fn pair_unsynced_tag_paths_are_the_two_filtered_types() {
478        for hit in [
479            "ignition/tag-definition",
480            "ignition/tag-definition/default",
481            "ignition/tag-definition/default/Deploy/tags.json",
482            "ignition/tag-type-definition",
483            "ignition/tag-type-definition/default/A/B/udts.json",
484        ] {
485            assert!(is_pair_unsynced_tag_path(hit), "{hit}");
486        }
487        for miss in [
488            "ignition",
489            "ignition/tag-group/default/Fast/config.json",
490            "ignition/tag-provider/default/config.json",
491            "ignition/tag-definitions/default/tags.json",
492            "ignition/Tag-Definition/default/tags.json",
493            "other/tag-definition/default/tags.json",
494            "ignition/database-translator/POSTGRES/config.json",
495        ] {
496            assert!(!is_pair_unsynced_tag_path(miss), "{miss}");
497        }
498    }
499}