1#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
16pub struct RelPath(String);
17
18impl RelPath {
19 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 pub fn as_str(&self) -> &str {
45 &self.0
46 }
47
48 pub fn components(&self) -> std::str::Split<'_, char> {
50 self.0.split('/')
51 }
52
53 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
69pub struct ResourceKey {
70 pub module: String,
72 pub ty: String,
74 pub name: Option<String>,
76}
77
78pub const TAG_TYPES: [&str; 4] = [
81 "tag-provider",
82 "tag-group",
83 "tag-type-definition",
84 "tag-definition",
85];
86
87pub const PAIR_UNSYNCED_TAG_TYPES: [&str; 2] = ["tag-type-definition", "tag-definition"];
99
100pub 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
111pub const CORE_KEY_PREFIX: &str = ".core/";
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum KeyCollection {
131 Mode,
133 Core,
135}
136
137pub 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
147pub fn core_key(path: &str) -> String {
149 format!("{CORE_KEY_PREFIX}{path}")
150}
151
152pub 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
165pub 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
216pub 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
258fn 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 RelPath::new(&format!("ignition/{ty}/{provider}/{}", tail.join("/")))
274}
275
276pub 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
285pub 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 (
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 (
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 (
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 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 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 "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 assert!(repo_path(&rp("ignition/config.json")).is_err());
470 assert!(repo_path(&rp("config.json")).is_err());
471 assert!(repo_path(&rp("ignition/tag-definition/P")).is_err());
473 assert!(repo_path(&rp("ignition/tag-definition/x.json")).is_err());
474 assert!(repo_path(&rp("ignition/tag-other/P/x.json")).is_err());
476 }
477
478 use proptest::prelude::*;
479
480 proptest! {
481 #[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 #[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 #[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 assert!(RelPath::new(&core_key(tag)).is_ok());
586 }
587}