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 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
162pub 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
204fn 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 RelPath::new(&format!("ignition/{ty}/{provider}/{}", tail.join("/")))
220}
221
222pub 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
231pub 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 (
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 (
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 (
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 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 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 "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 assert!(repo_path(&rp("ignition/config.json")).is_err());
416 assert!(repo_path(&rp("config.json")).is_err());
417 assert!(repo_path(&rp("ignition/tag-definition/P")).is_err());
419 assert!(repo_path(&rp("ignition/tag-definition/x.json")).is_err());
420 assert!(repo_path(&rp("ignition/tag-other/P/x.json")).is_err());
422 }
423
424 use proptest::prelude::*;
425
426 proptest! {
427 #[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 #[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}