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 fn collection_path(repo_rel: &RelPath) -> Result<RelPath, String> {
124 if let Some(rest) = repo_rel.strip_prefix("tags") {
125 return tag_collection_path(&rest);
126 }
127 let rest = repo_rel
128 .strip_prefix("resources")
129 .ok_or_else(|| "not under `resources/` or `tags/`".to_string())?;
130 if rest.components().count() < 3 {
131 return Err(
132 "a resource file must sit at `resources/<module>/<type>/<file>` or deeper".to_string(),
133 );
134 }
135 Ok(rest)
136}
137
138pub fn repo_path(collection: &RelPath) -> Result<RelPath, String> {
152 let parts: Vec<&str> = collection.components().collect();
153 if parts.len() < 3 {
154 return Err(
155 "a resource file must sit at `<module>/<type>/<file>` or deeper in a collection"
156 .to_string(),
157 );
158 }
159 if parts[0] == "ignition" {
160 let ty = parts[1];
161 if TAG_TYPES.contains(&ty) {
162 if parts.len() < 4 {
163 return Err(
164 "a tag file must sit at `ignition/<tag type>/<provider>/<file>` or deeper"
165 .to_string(),
166 );
167 }
168 return RelPath::new(&format!("tags/{}/{ty}/{}", parts[2], parts[3..].join("/")));
169 }
170 if ty.starts_with("tag-") {
171 return Err(format!(
172 "an `ignition/tag-*` type that is not one of the tag types ({})",
173 TAG_TYPES.join(", ")
174 ));
175 }
176 }
177 RelPath::new(&format!("resources/{collection}"))
178}
179
180fn tag_collection_path(rest: &RelPath) -> Result<RelPath, String> {
182 let parts: Vec<&str> = rest.components().collect();
183 if parts.len() < 3 {
184 return Err("a tag file must sit at `tags/<provider>/<type>/<file>` or deeper".to_string());
185 }
186 let (provider, ty, tail) = (parts[0], parts[1], &parts[2..]);
187 if !TAG_TYPES.contains(&ty) {
188 return Err(format!(
189 "`{ty}` is not a tag type (expected one of: {})",
190 TAG_TYPES.join(", ")
191 ));
192 }
193 RelPath::new(&format!("ignition/{ty}/{provider}/{}", tail.join("/")))
196}
197
198pub fn tag_provider(repo_rel: &RelPath) -> Option<&str> {
200 let mut parts = repo_rel.components();
201 if parts.next() != Some("tags") {
202 return None;
203 }
204 parts.next()
205}
206
207pub fn resource_key(collection: &RelPath) -> ResourceKey {
211 let parts: Vec<&str> = collection.components().collect();
212 ResourceKey {
213 module: parts.first().copied().unwrap_or_default().to_string(),
214 ty: parts.get(1).copied().unwrap_or_default().to_string(),
215 name: if parts.len() >= 4 {
216 Some(parts[2].to_string())
217 } else {
218 None
219 },
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn rp(s: &str) -> RelPath {
228 RelPath::new(s).unwrap()
229 }
230
231 #[test]
232 fn valid_paths_round_trip() {
233 for ok in ["a", "a/b", "résumé/x", "A/a", "a b/c.json"] {
234 assert_eq!(RelPath::new(ok).unwrap().as_str(), ok);
235 }
236 }
237
238 #[test]
239 fn hostile_paths_are_refused() {
240 for bad in [
241 "", "/abs", "a\\b", "a//b", "a/", "./a", "a/./b", "a/..", "../a", "a\0b",
242 ] {
243 assert!(RelPath::new(bad).is_err(), "{bad:?}");
244 }
245 }
246
247 #[test]
248 fn ordering_is_utf8_byte_order() {
249 let mut v = [rp("b"), rp("a"), rp("B"), rp("é"), rp("Z")];
250 v.sort();
251 let got: Vec<&str> = v.iter().map(RelPath::as_str).collect();
252 assert_eq!(got, ["B", "Z", "a", "b", "é"]);
253 }
254
255 #[test]
256 fn strip_prefix_needs_a_whole_component() {
257 assert_eq!(
258 rp("resources/a/b").strip_prefix("resources"),
259 Some(rp("a/b"))
260 );
261 assert_eq!(rp("resourcesx/a").strip_prefix("resources"), None);
262 assert_eq!(rp("resources").strip_prefix("resources"), None);
263 }
264
265 #[test]
266 fn collection_path_requires_three_components() {
267 assert_eq!(
268 collection_path(&rp("resources/ignition/x/config.json")).unwrap(),
269 rp("ignition/x/config.json")
270 );
271 assert!(collection_path(&rp("resources/config.json")).is_err());
272 assert!(collection_path(&rp("resources/ignition/config.json")).is_err());
273 assert!(collection_path(&rp("other/ignition/x/y")).is_err());
274 }
275
276 #[test]
277 fn tags_map_per_cd5() {
278 let cases = [
279 (
280 "tags/P/tag-provider/config.json",
281 "ignition/tag-provider/P/config.json",
282 ),
283 (
284 "tags/P/tag-group/Default/resource.json",
285 "ignition/tag-group/P/Default/resource.json",
286 ),
287 (
288 "tags/P/tag-type-definition/udts.json",
289 "ignition/tag-type-definition/P/udts.json",
290 ),
291 (
292 "tags/P/tag-definition/unary-resource.json",
293 "ignition/tag-definition/P/unary-resource.json",
294 ),
295 (
296 "tags/P/tag-definition/FolderA/Sub/tags.json",
297 "ignition/tag-definition/P/FolderA/Sub/tags.json",
298 ),
299 (
301 "tags/P/tag-definition/tags.json",
302 "ignition/tag-definition/P/tags.json",
303 ),
304 (
305 "tags/P/tag-definition/udts.json",
306 "ignition/tag-definition/P/udts.json",
307 ),
308 (
310 "tags/P/tag-type-definition/A/B/udts.json",
311 "ignition/tag-type-definition/P/A/B/udts.json",
312 ),
313 (
314 "tags/P/tag-type-definition/A/B/unary-resource.json",
315 "ignition/tag-type-definition/P/A/B/unary-resource.json",
316 ),
317 (
318 "tags/P/tag-type-definition/A/unary-resource.json",
319 "ignition/tag-type-definition/P/A/unary-resource.json",
320 ),
321 (
324 "tags/P/tag-type-definition/_types_/udts.json",
325 "ignition/tag-type-definition/P/_types_/udts.json",
326 ),
327 ];
328 for (from, to) in cases {
329 assert_eq!(collection_path(&rp(from)).unwrap(), rp(to), "{from}");
330 }
331 assert_eq!(tag_provider(&rp("tags/P/tag-group/x")), Some("P"));
332 assert_eq!(tag_provider(&rp("resources/a/b/c")), None);
333 }
334
335 #[test]
336 fn unmapped_tag_layouts_stay_fail_closed() {
337 for bad in [
340 "tags/P/other/x.json",
341 "tags/P/x.json",
342 "tags/x.json",
343 "tags/P/tag-definition",
344 ] {
345 assert!(collection_path(&rp(bad)).is_err(), "{bad}");
346 }
347 }
348
349 #[test]
350 fn repo_path_inverts_collection_path() {
351 let cases = [
353 "ignition/general-alarm-settings/config.json",
354 "ignition/database-translator/POSTGRES/config.json",
355 "ignition/database-translator/POSTGRES/resource.json",
356 "com.example.mod/thing/A B/x/y.json",
357 "ignition/tag-provider/P/config.json",
358 "ignition/tag-group/P/Default/resource.json",
359 "ignition/tag-type-definition/P/udts.json",
360 "ignition/tag-definition/P/unary-resource.json",
361 "ignition/tag-definition/P/FolderA/Sub/tags.json",
362 "ignition/tag-definition/P/tags.json",
364 "ignition/tag-definition/P/udts.json",
365 "ignition/tag-type-definition/P/A/B/udts.json",
366 "ignition/tag-type-definition/P/A/B/unary-resource.json",
367 "ignition/tag-type-definition/P/A/unary-resource.json",
368 ];
369 for collection in cases {
370 let c = rp(collection);
371 let repo = repo_path(&c).unwrap_or_else(|e| panic!("{collection}: {e}"));
372 assert_eq!(
373 collection_path(&repo).unwrap(),
374 c,
375 "{collection} via {repo}"
376 );
377 }
378 assert_eq!(
379 repo_path(&rp("ignition/tag-group/P/Default/resource.json")).unwrap(),
380 rp("tags/P/tag-group/Default/resource.json")
381 );
382 assert_eq!(
383 repo_path(&rp("ignition/database-translator/X/config.json")).unwrap(),
384 rp("resources/ignition/database-translator/X/config.json")
385 );
386 }
387
388 #[test]
389 fn repo_path_refuses_what_has_no_layout() {
390 assert!(repo_path(&rp("ignition/config.json")).is_err());
392 assert!(repo_path(&rp("config.json")).is_err());
393 assert!(repo_path(&rp("ignition/tag-definition/P")).is_err());
395 assert!(repo_path(&rp("ignition/tag-definition/x.json")).is_err());
396 assert!(repo_path(&rp("ignition/tag-other/P/x.json")).is_err());
398 }
399
400 use proptest::prelude::*;
401
402 proptest! {
403 #[test]
408 fn cd5_tag_paths_round_trip(
409 provider in "[A-Za-z0-9_]{1,12}",
410 ty in prop::sample::select(TAG_TYPES.to_vec()),
411 tail in prop::collection::vec("[A-Za-z0-9_]{1,12}", 1..=6),
412 ) {
413 let repo = rp(&format!("tags/{provider}/{ty}/{}", tail.join("/")));
414 let collection = collection_path(&repo).expect("every tag layout maps");
415 prop_assert_eq!(
416 collection.as_str(),
417 format!("ignition/{ty}/{provider}/{}", tail.join("/"))
418 );
419 prop_assert_eq!(&repo_path(&collection).expect("inverse"), &repo);
420 prop_assert_eq!(&collection_path(&repo_path(&collection).unwrap()).unwrap(), &collection);
421 }
422 }
423
424 #[test]
425 fn resource_key_names_only_with_a_fourth_component() {
426 let singleton = resource_key(&rp("ignition/general-alarm-settings/config.json"));
427 assert_eq!(singleton.name, None);
428 let named = resource_key(&rp("ignition/database-translator/POSTGRES/config.json"));
429 assert_eq!(named.module, "ignition");
430 assert_eq!(named.ty, "database-translator");
431 assert_eq!(named.name.as_deref(), Some("POSTGRES"));
432 }
433
434 #[test]
435 fn singleton_key_orders_before_named_of_same_type() {
436 let a = ResourceKey {
437 module: "m".into(),
438 ty: "t".into(),
439 name: None,
440 };
441 let b = ResourceKey {
442 module: "m".into(),
443 ty: "t".into(),
444 name: Some("A".into()),
445 };
446 assert!(a < b);
447 }
448}