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/// The prefix that names the `core` collection in the write half's keys
112/// (quick 261102-cef, A2): `.core/<collection path>`.
113///
114/// On a redundant pair, `tag-definition` and `tag-type-definition` units are
115/// written into `core`, which `tag-config-resource` syncs (debug
116/// deploy-tag-redundancy-sync, Evidence C1/C3), and not into the mode
117/// collection. The plan's file rows and delete set, the ledger, the write and
118/// delete ops, the journal and record `touched` lists, the fingerprint keys
119/// and the work list spell such a path with this prefix; a mode path stays
120/// bare, so a standalone or tag-free run is byte-identical to one before A2.
121/// A leading dot component is refused by every validator of an older build
122/// (`mirror_path_problem`, the ledger's and the journal's checks), so that
123/// build fails closed on a newer ledger, journal or record instead of writing
124/// `<mode>/.core/...`. Only a path under one of the
125/// [`PAIR_UNSYNCED_TAG_TYPES`] may carry it.
126pub const CORE_KEY_PREFIX: &str = ".core/";
127
128/// The collection a write-half key names ([`split_key`]).
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum KeyCollection {
131    /// A bare key: the mode collection.
132    Mode,
133    /// A [`CORE_KEY_PREFIX`] key: the `core` collection.
134    Core,
135}
136
137/// The collection `key` names and its collection path: a
138/// [`CORE_KEY_PREFIX`] key is `core` and the rest after the prefix, any
139/// other key is the mode collection and the key itself.
140pub fn split_key(key: &str) -> (KeyCollection, &str) {
141    match key.strip_prefix(CORE_KEY_PREFIX) {
142        Some(rest) => (KeyCollection::Core, rest),
143        None => (KeyCollection::Mode, key),
144    }
145}
146
147/// The `core` key of the collection path `path`.
148pub fn core_key(path: &str) -> String {
149    format!("{CORE_KEY_PREFIX}{path}")
150}
151
152/// The write-half key of the collection path `path` on a target of
153/// `redundancy` (decision D1 of quick 261102-cef): its [`core_key`] when the
154/// target is a redundant pair and `path` is a
155/// [`PAIR_UNSYNCED_TAG_TYPES`] path ([`is_pair_unsynced_tag_path`]), else
156/// `path` itself. The one routing rule plan, apply and verify share.
157pub fn route(redundancy: super::target::Redundancy, path: &str) -> String {
158    if redundancy == super::target::Redundancy::Pair && is_pair_unsynced_tag_path(path) {
159        core_key(path)
160    } else {
161        path.to_string()
162    }
163}
164
165/// Gateway-relative path to collection path.
166///
167/// Two layouts are accepted:
168///
169/// - `resources/<m>/<t>/...` to `<m>/<t>/...`. Refuses a path with fewer than
170///   three remaining components (a file directly under `resources/` or
171///   `resources/<module>/` is not a resource file).
172/// - `tags/<P>/<type>/<rest>` to `ignition/<type>/<P>/<rest>` for the four
173///   [`TAG_TYPES`]. This is decision **CD5** of `21-LIVE-CAPTURES.md`,
174///   captured on an 8.3.6 gateway: the type and provider segments swap
175///   (confirming RESEARCH assumption A5, so CD5 and the research guess
176///   agree). Folders nest as directories; `tag-group` entries are named
177///   resources (`<P>/<Group>/{config,resource}.json`) while
178///   `tag-type-definition` and `tag-definition` carry `udts.json`,
179///   `tags.json` and `unary-resource.json`.
180///
181/// Two layouts CD5 left unexercised were captured later on an 8.3.6 Docker
182/// rig and the Windows VM (`261030-8m8-LIVE-CAPTURES.md`) and are mapped
183/// read-write like folder tags:
184///
185/// - **CD5a**, root-level tags: `tags/<P>/tag-definition/tags.json` (and
186///   `udts.json`) to `ignition/tag-definition/<P>/tags.json`. The gateway
187///   writes them beside the provider-root `unary-resource.json`, so they
188///   join the provider-root unit `ignition/tag-definition/<P>`.
189/// - **CD5b**, nested type folders: `tags/<P>/tag-type-definition/A/B/...`
190///   to `ignition/tag-type-definition/<P>/A/B/...`. A type in `_types_/A/B`
191///   lands in `A/B/` (no `_types_` directory exists); every folder carries
192///   its own marker, a folder holding only sub-folders with `files: []`, so
193///   each folder is its own unit.
194///
195/// One write, change or delete of either layout takes effect after one
196/// config scan, as for folder tags (CD5-CTL). Render checks each marker's
197/// `files` against its member files (CD5c) and refuses a folder named like a
198/// member file (CD5d); both live in render, not here. The one layout still
199/// unexercised is a collection other than the default one, which this
200/// mapping cannot express (it has no collection concept).
201pub fn collection_path(repo_rel: &RelPath) -> Result<RelPath, String> {
202    if let Some(rest) = repo_rel.strip_prefix("tags") {
203        return tag_collection_path(&rest);
204    }
205    let rest = repo_rel
206        .strip_prefix("resources")
207        .ok_or_else(|| "not under `resources/` or `tags/`".to_string())?;
208    if rest.components().count() < 3 {
209        return Err(
210            "a resource file must sit at `resources/<module>/<type>/<file>` or deeper".to_string(),
211        );
212    }
213    Ok(rest)
214}
215
216/// Collection path to gateway-relative repo path: the exact inverse of
217/// [`collection_path`] (CD5), used by `deploy pull` to place live files.
218///
219/// - `ignition/<tagtype>/<P>/<rest>` with `tagtype` one of [`TAG_TYPES`]
220///   becomes `tags/<P>/<tagtype>/<rest>`;
221/// - any other path with at least three components becomes
222///   `resources/<collection>`.
223///
224/// Fail-closed: a tag type with no provider or no file below it, an
225/// `ignition/tag-*` type that is not one of the four tag types, and a path
226/// with fewer than three components are errors. Every path this function
227/// accepts maps back through [`collection_path`] (root-level tags CD5a and
228/// nested type folders CD5b included). Messages carry no names.
229pub fn repo_path(collection: &RelPath) -> Result<RelPath, String> {
230    let parts: Vec<&str> = collection.components().collect();
231    if parts.len() < 3 {
232        return Err(
233            "a resource file must sit at `<module>/<type>/<file>` or deeper in a collection"
234                .to_string(),
235        );
236    }
237    if parts[0] == "ignition" {
238        let ty = parts[1];
239        if TAG_TYPES.contains(&ty) {
240            if parts.len() < 4 {
241                return Err(
242                    "a tag file must sit at `ignition/<tag type>/<provider>/<file>` or deeper"
243                        .to_string(),
244                );
245            }
246            return RelPath::new(&format!("tags/{}/{ty}/{}", parts[2], parts[3..].join("/")));
247        }
248        if ty.starts_with("tag-") {
249            return Err(format!(
250                "an `ignition/tag-*` type that is not one of the tag types ({})",
251                TAG_TYPES.join(", ")
252            ));
253        }
254    }
255    RelPath::new(&format!("resources/{collection}"))
256}
257
258/// `<P>/<type>/<rest>` (the part after `tags/`) to `ignition/<type>/<P>/<rest>`.
259fn tag_collection_path(rest: &RelPath) -> Result<RelPath, String> {
260    let parts: Vec<&str> = rest.components().collect();
261    if parts.len() < 3 {
262        return Err("a tag file must sit at `tags/<provider>/<type>/<file>` or deeper".to_string());
263    }
264    let (provider, ty, tail) = (parts[0], parts[1], &parts[2..]);
265    if !TAG_TYPES.contains(&ty) {
266        return Err(format!(
267            "`{ty}` is not a tag type (expected one of: {})",
268            TAG_TYPES.join(", ")
269        ));
270    }
271    // Any tail maps: root-level tags (CD5a) and nested type folders (CD5b)
272    // were captured and are accepted (261030-8m8-LIVE-CAPTURES.md).
273    RelPath::new(&format!("ignition/{ty}/{provider}/{}", tail.join("/")))
274}
275
276/// The tag provider directory a gateway-relative `tags/<P>/...` path sits in.
277pub fn tag_provider(repo_rel: &RelPath) -> Option<&str> {
278    let mut parts = repo_rel.components();
279    if parts.next() != Some("tags") {
280        return None;
281    }
282    parts.next()
283}
284
285/// The resource a collection path belongs to: module and type from
286/// components 1 and 2, the name from component 3 only when a 4th exists
287/// (otherwise component 3 is a file of a singleton).
288pub fn resource_key(collection: &RelPath) -> ResourceKey {
289    let parts: Vec<&str> = collection.components().collect();
290    ResourceKey {
291        module: parts.first().copied().unwrap_or_default().to_string(),
292        ty: parts.get(1).copied().unwrap_or_default().to_string(),
293        name: if parts.len() >= 4 {
294            Some(parts[2].to_string())
295        } else {
296            None
297        },
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    fn rp(s: &str) -> RelPath {
306        RelPath::new(s).unwrap()
307    }
308
309    #[test]
310    fn valid_paths_round_trip() {
311        for ok in ["a", "a/b", "résumé/x", "A/a", "a b/c.json"] {
312            assert_eq!(RelPath::new(ok).unwrap().as_str(), ok);
313        }
314    }
315
316    #[test]
317    fn hostile_paths_are_refused() {
318        for bad in [
319            "", "/abs", "a\\b", "a//b", "a/", "./a", "a/./b", "a/..", "../a", "a\0b",
320        ] {
321            assert!(RelPath::new(bad).is_err(), "{bad:?}");
322        }
323    }
324
325    #[test]
326    fn ordering_is_utf8_byte_order() {
327        let mut v = [rp("b"), rp("a"), rp("B"), rp("é"), rp("Z")];
328        v.sort();
329        let got: Vec<&str> = v.iter().map(RelPath::as_str).collect();
330        assert_eq!(got, ["B", "Z", "a", "b", "é"]);
331    }
332
333    #[test]
334    fn strip_prefix_needs_a_whole_component() {
335        assert_eq!(
336            rp("resources/a/b").strip_prefix("resources"),
337            Some(rp("a/b"))
338        );
339        assert_eq!(rp("resourcesx/a").strip_prefix("resources"), None);
340        assert_eq!(rp("resources").strip_prefix("resources"), None);
341    }
342
343    #[test]
344    fn collection_path_requires_three_components() {
345        assert_eq!(
346            collection_path(&rp("resources/ignition/x/config.json")).unwrap(),
347            rp("ignition/x/config.json")
348        );
349        assert!(collection_path(&rp("resources/config.json")).is_err());
350        assert!(collection_path(&rp("resources/ignition/config.json")).is_err());
351        assert!(collection_path(&rp("other/ignition/x/y")).is_err());
352    }
353
354    #[test]
355    fn tags_map_per_cd5() {
356        let cases = [
357            (
358                "tags/P/tag-provider/config.json",
359                "ignition/tag-provider/P/config.json",
360            ),
361            (
362                "tags/P/tag-group/Default/resource.json",
363                "ignition/tag-group/P/Default/resource.json",
364            ),
365            (
366                "tags/P/tag-type-definition/udts.json",
367                "ignition/tag-type-definition/P/udts.json",
368            ),
369            (
370                "tags/P/tag-definition/unary-resource.json",
371                "ignition/tag-definition/P/unary-resource.json",
372            ),
373            (
374                "tags/P/tag-definition/FolderA/Sub/tags.json",
375                "ignition/tag-definition/P/FolderA/Sub/tags.json",
376            ),
377            // CD5a (261030-8m8): root-level tags join the provider-root unit
378            (
379                "tags/P/tag-definition/tags.json",
380                "ignition/tag-definition/P/tags.json",
381            ),
382            (
383                "tags/P/tag-definition/udts.json",
384                "ignition/tag-definition/P/udts.json",
385            ),
386            // CD5b (261030-8m8): nested type folders, each its own unit
387            (
388                "tags/P/tag-type-definition/A/B/udts.json",
389                "ignition/tag-type-definition/P/A/B/udts.json",
390            ),
391            (
392                "tags/P/tag-type-definition/A/B/unary-resource.json",
393                "ignition/tag-type-definition/P/A/B/unary-resource.json",
394            ),
395            (
396                "tags/P/tag-type-definition/A/unary-resource.json",
397                "ignition/tag-type-definition/P/A/unary-resource.json",
398            ),
399            // `_types_` is never a directory (CD5b); a folder of that name is
400            // a plain nested folder
401            (
402                "tags/P/tag-type-definition/_types_/udts.json",
403                "ignition/tag-type-definition/P/_types_/udts.json",
404            ),
405        ];
406        for (from, to) in cases {
407            assert_eq!(collection_path(&rp(from)).unwrap(), rp(to), "{from}");
408        }
409        assert_eq!(tag_provider(&rp("tags/P/tag-group/x")), Some("P"));
410        assert_eq!(tag_provider(&rp("resources/a/b/c")), None);
411    }
412
413    #[test]
414    fn unmapped_tag_layouts_stay_fail_closed() {
415        // CD5a and CD5b lifted the root-level and nested refusals; what has
416        // no tag type or no file below the type is still refused
417        for bad in [
418            "tags/P/other/x.json",
419            "tags/P/x.json",
420            "tags/x.json",
421            "tags/P/tag-definition",
422        ] {
423            assert!(collection_path(&rp(bad)).is_err(), "{bad}");
424        }
425    }
426
427    #[test]
428    fn repo_path_inverts_collection_path() {
429        // resource and tag collection paths, including nested tag folders
430        let cases = [
431            "ignition/general-alarm-settings/config.json",
432            "ignition/database-translator/POSTGRES/config.json",
433            "ignition/database-translator/POSTGRES/resource.json",
434            "com.example.mod/thing/A B/x/y.json",
435            "ignition/tag-provider/P/config.json",
436            "ignition/tag-group/P/Default/resource.json",
437            "ignition/tag-type-definition/P/udts.json",
438            "ignition/tag-definition/P/unary-resource.json",
439            "ignition/tag-definition/P/FolderA/Sub/tags.json",
440            // CD5a and CD5b (261030-8m8)
441            "ignition/tag-definition/P/tags.json",
442            "ignition/tag-definition/P/udts.json",
443            "ignition/tag-type-definition/P/A/B/udts.json",
444            "ignition/tag-type-definition/P/A/B/unary-resource.json",
445            "ignition/tag-type-definition/P/A/unary-resource.json",
446        ];
447        for collection in cases {
448            let c = rp(collection);
449            let repo = repo_path(&c).unwrap_or_else(|e| panic!("{collection}: {e}"));
450            assert_eq!(
451                collection_path(&repo).unwrap(),
452                c,
453                "{collection} via {repo}"
454            );
455        }
456        assert_eq!(
457            repo_path(&rp("ignition/tag-group/P/Default/resource.json")).unwrap(),
458            rp("tags/P/tag-group/Default/resource.json")
459        );
460        assert_eq!(
461            repo_path(&rp("ignition/database-translator/X/config.json")).unwrap(),
462            rp("resources/ignition/database-translator/X/config.json")
463        );
464    }
465
466    #[test]
467    fn repo_path_refuses_what_has_no_layout() {
468        // too short for a resource file
469        assert!(repo_path(&rp("ignition/config.json")).is_err());
470        assert!(repo_path(&rp("config.json")).is_err());
471        // a tag type with no provider or no file below it
472        assert!(repo_path(&rp("ignition/tag-definition/P")).is_err());
473        assert!(repo_path(&rp("ignition/tag-definition/x.json")).is_err());
474        // a tag-looking type that is not one of the four tag types
475        assert!(repo_path(&rp("ignition/tag-other/P/x.json")).is_err());
476    }
477
478    use proptest::prelude::*;
479
480    proptest! {
481        /// CD5a/CD5b (261030-8m8): for every tag type and any folder chain
482        /// of 1 to 6 segments below the provider, `collection_path` and
483        /// `repo_path` are exact inverses. No tag layout is refused any more,
484        /// so nothing is assumed out.
485        #[test]
486        fn cd5_tag_paths_round_trip(
487            provider in "[A-Za-z0-9_]{1,12}",
488            ty in prop::sample::select(TAG_TYPES.to_vec()),
489            tail in prop::collection::vec("[A-Za-z0-9_]{1,12}", 1..=6),
490        ) {
491            let repo = rp(&format!("tags/{provider}/{ty}/{}", tail.join("/")));
492            let collection = collection_path(&repo).expect("every tag layout maps");
493            prop_assert_eq!(
494                collection.as_str(),
495                format!("ignition/{ty}/{provider}/{}", tail.join("/"))
496            );
497            prop_assert_eq!(&repo_path(&collection).expect("inverse"), &repo);
498            prop_assert_eq!(&collection_path(&repo_path(&collection).unwrap()).unwrap(), &collection);
499        }
500    }
501
502    #[test]
503    fn resource_key_names_only_with_a_fourth_component() {
504        let singleton = resource_key(&rp("ignition/general-alarm-settings/config.json"));
505        assert_eq!(singleton.name, None);
506        let named = resource_key(&rp("ignition/database-translator/POSTGRES/config.json"));
507        assert_eq!(named.module, "ignition");
508        assert_eq!(named.ty, "database-translator");
509        assert_eq!(named.name.as_deref(), Some("POSTGRES"));
510    }
511
512    #[test]
513    fn singleton_key_orders_before_named_of_same_type() {
514        let a = ResourceKey {
515            module: "m".into(),
516            ty: "t".into(),
517            name: None,
518        };
519        let b = ResourceKey {
520            module: "m".into(),
521            ty: "t".into(),
522            name: Some("A".into()),
523        };
524        assert!(a < b);
525    }
526
527    /// debug deploy-tag-redundancy-sync: exactly the two types the pair's
528    /// `config-resource` filter leaves out (T6, C5, C6), from the type folder
529    /// down, by exact bytes.
530    #[test]
531    fn pair_unsynced_tag_paths_are_the_two_filtered_types() {
532        for hit in [
533            "ignition/tag-definition",
534            "ignition/tag-definition/default",
535            "ignition/tag-definition/default/Deploy/tags.json",
536            "ignition/tag-type-definition",
537            "ignition/tag-type-definition/default/A/B/udts.json",
538        ] {
539            assert!(is_pair_unsynced_tag_path(hit), "{hit}");
540        }
541        for miss in [
542            "ignition",
543            "ignition/tag-group/default/Fast/config.json",
544            "ignition/tag-provider/default/config.json",
545            "ignition/tag-definitions/default/tags.json",
546            "ignition/Tag-Definition/default/tags.json",
547            "other/tag-definition/default/tags.json",
548            "ignition/database-translator/POSTGRES/config.json",
549        ] {
550            assert!(!is_pair_unsynced_tag_path(miss), "{miss}");
551        }
552    }
553
554    /// quick 261102-cef (A2): on a pair only the two tag types are routed to
555    /// `core`; a standalone target routes nothing, and `split_key` undoes
556    /// `core_key` exactly.
557    #[test]
558    fn core_key_route_qualifies_only_pair_tag_types() {
559        use super::super::target::Redundancy;
560        let tag = "ignition/tag-definition/default/Deploy/tags.json";
561        let udt = "ignition/tag-type-definition/default/T/udts.json";
562        assert_eq!(
563            route(Redundancy::Pair, tag),
564            ".core/ignition/tag-definition/default/Deploy/tags.json"
565        );
566        assert_eq!(
567            route(Redundancy::Pair, udt),
568            ".core/ignition/tag-type-definition/default/T/udts.json"
569        );
570        for bare in [
571            "ignition/tag-group/default/G/config.json",
572            "ignition/tag-provider/default/config.json",
573            "ignition/database-translator/POSTGRES/config.json",
574        ] {
575            assert_eq!(route(Redundancy::Pair, bare), bare);
576        }
577        assert_eq!(route(Redundancy::Standalone, tag), tag);
578        assert_eq!(route(Redundancy::Standalone, udt), udt);
579
580        assert_eq!(split_key(&core_key(tag)), (KeyCollection::Core, tag));
581        assert_eq!(split_key(tag), (KeyCollection::Mode, tag));
582        assert_eq!(split_key(".core"), (KeyCollection::Mode, ".core"));
583        assert_eq!(CORE_KEY_PREFIX, ".core/");
584        // the key is a valid RelPath, so it rides every path-typed field
585        assert!(RelPath::new(&core_key(tag)).is_ok());
586    }
587}