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> {
109 if let Some(rest) = repo_rel.strip_prefix("tags") {
110 return tag_collection_path(&rest);
111 }
112 let rest = repo_rel
113 .strip_prefix("resources")
114 .ok_or_else(|| "not under `resources/` or `tags/`".to_string())?;
115 if rest.components().count() < 3 {
116 return Err(
117 "a resource file must sit at `resources/<module>/<type>/<file>` or deeper".to_string(),
118 );
119 }
120 Ok(rest)
121}
122
123pub fn repo_path(collection: &RelPath) -> Result<RelPath, String> {
138 let parts: Vec<&str> = collection.components().collect();
139 if parts.len() < 3 {
140 return Err(
141 "a resource file must sit at `<module>/<type>/<file>` or deeper in a collection"
142 .to_string(),
143 );
144 }
145 if parts[0] == "ignition" {
146 let ty = parts[1];
147 if TAG_TYPES.contains(&ty) {
148 if parts.len() < 4 {
149 return Err(
150 "a tag file must sit at `ignition/<tag type>/<provider>/<file>` or deeper"
151 .to_string(),
152 );
153 }
154 return RelPath::new(&format!("tags/{}/{ty}/{}", parts[2], parts[3..].join("/")));
155 }
156 if ty.starts_with("tag-") {
157 return Err(format!(
158 "an `ignition/tag-*` type that is not one of the tag types ({})",
159 TAG_TYPES.join(", ")
160 ));
161 }
162 }
163 RelPath::new(&format!("resources/{collection}"))
164}
165
166fn tag_collection_path(rest: &RelPath) -> Result<RelPath, String> {
168 let parts: Vec<&str> = rest.components().collect();
169 if parts.len() < 3 {
170 return Err("a tag file must sit at `tags/<provider>/<type>/<file>` or deeper".to_string());
171 }
172 let (provider, ty, tail) = (parts[0], parts[1], &parts[2..]);
173 if !TAG_TYPES.contains(&ty) {
174 return Err(format!(
175 "`{ty}` is not a tag type (expected one of: {})",
176 TAG_TYPES.join(", ")
177 ));
178 }
179 if ty == "tag-definition" && tail.len() == 1 && matches!(tail[0], "tags.json" | "udts.json") {
180 return Err(
181 "root-level tags (a tags.json or udts.json at the provider root) were not captured against a gateway (CD5) and are refused; put tags in a folder".to_string(),
182 );
183 }
184 if ty == "tag-type-definition" && tail.len() > 1 {
185 return Err(
186 "nested folders under tag-type-definition were not captured against a gateway (CD5) and are refused".to_string(),
187 );
188 }
189 RelPath::new(&format!("ignition/{ty}/{provider}/{}", tail.join("/")))
190}
191
192pub fn tag_provider(repo_rel: &RelPath) -> Option<&str> {
194 let mut parts = repo_rel.components();
195 if parts.next() != Some("tags") {
196 return None;
197 }
198 parts.next()
199}
200
201pub fn resource_key(collection: &RelPath) -> ResourceKey {
205 let parts: Vec<&str> = collection.components().collect();
206 ResourceKey {
207 module: parts.first().copied().unwrap_or_default().to_string(),
208 ty: parts.get(1).copied().unwrap_or_default().to_string(),
209 name: if parts.len() >= 4 {
210 Some(parts[2].to_string())
211 } else {
212 None
213 },
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 fn rp(s: &str) -> RelPath {
222 RelPath::new(s).unwrap()
223 }
224
225 #[test]
226 fn valid_paths_round_trip() {
227 for ok in ["a", "a/b", "résumé/x", "A/a", "a b/c.json"] {
228 assert_eq!(RelPath::new(ok).unwrap().as_str(), ok);
229 }
230 }
231
232 #[test]
233 fn hostile_paths_are_refused() {
234 for bad in [
235 "", "/abs", "a\\b", "a//b", "a/", "./a", "a/./b", "a/..", "../a", "a\0b",
236 ] {
237 assert!(RelPath::new(bad).is_err(), "{bad:?}");
238 }
239 }
240
241 #[test]
242 fn ordering_is_utf8_byte_order() {
243 let mut v = [rp("b"), rp("a"), rp("B"), rp("é"), rp("Z")];
244 v.sort();
245 let got: Vec<&str> = v.iter().map(RelPath::as_str).collect();
246 assert_eq!(got, ["B", "Z", "a", "b", "é"]);
247 }
248
249 #[test]
250 fn strip_prefix_needs_a_whole_component() {
251 assert_eq!(
252 rp("resources/a/b").strip_prefix("resources"),
253 Some(rp("a/b"))
254 );
255 assert_eq!(rp("resourcesx/a").strip_prefix("resources"), None);
256 assert_eq!(rp("resources").strip_prefix("resources"), None);
257 }
258
259 #[test]
260 fn collection_path_requires_three_components() {
261 assert_eq!(
262 collection_path(&rp("resources/ignition/x/config.json")).unwrap(),
263 rp("ignition/x/config.json")
264 );
265 assert!(collection_path(&rp("resources/config.json")).is_err());
266 assert!(collection_path(&rp("resources/ignition/config.json")).is_err());
267 assert!(collection_path(&rp("other/ignition/x/y")).is_err());
268 }
269
270 #[test]
271 fn tags_map_per_cd5() {
272 let cases = [
273 (
274 "tags/P/tag-provider/config.json",
275 "ignition/tag-provider/P/config.json",
276 ),
277 (
278 "tags/P/tag-group/Default/resource.json",
279 "ignition/tag-group/P/Default/resource.json",
280 ),
281 (
282 "tags/P/tag-type-definition/udts.json",
283 "ignition/tag-type-definition/P/udts.json",
284 ),
285 (
286 "tags/P/tag-definition/unary-resource.json",
287 "ignition/tag-definition/P/unary-resource.json",
288 ),
289 (
290 "tags/P/tag-definition/FolderA/Sub/tags.json",
291 "ignition/tag-definition/P/FolderA/Sub/tags.json",
292 ),
293 ];
294 for (from, to) in cases {
295 assert_eq!(collection_path(&rp(from)).unwrap(), rp(to), "{from}");
296 }
297 assert_eq!(tag_provider(&rp("tags/P/tag-group/x")), Some("P"));
298 assert_eq!(tag_provider(&rp("resources/a/b/c")), None);
299 }
300
301 #[test]
302 fn unexercised_tag_layouts_stay_fail_closed() {
303 for bad in [
304 "tags/P/other/x.json",
305 "tags/P/x.json",
306 "tags/x.json",
307 "tags/P/tag-definition/tags.json",
308 "tags/P/tag-definition/udts.json",
309 "tags/P/tag-type-definition/_types_/udts.json",
310 ] {
311 assert!(collection_path(&rp(bad)).is_err(), "{bad}");
312 }
313 }
314
315 #[test]
316 fn repo_path_inverts_collection_path() {
317 let cases = [
319 "ignition/general-alarm-settings/config.json",
320 "ignition/database-translator/POSTGRES/config.json",
321 "ignition/database-translator/POSTGRES/resource.json",
322 "com.example.mod/thing/A B/x/y.json",
323 "ignition/tag-provider/P/config.json",
324 "ignition/tag-group/P/Default/resource.json",
325 "ignition/tag-type-definition/P/udts.json",
326 "ignition/tag-definition/P/unary-resource.json",
327 "ignition/tag-definition/P/FolderA/Sub/tags.json",
328 ];
329 for collection in cases {
330 let c = rp(collection);
331 let repo = repo_path(&c).unwrap_or_else(|e| panic!("{collection}: {e}"));
332 assert_eq!(
333 collection_path(&repo).unwrap(),
334 c,
335 "{collection} via {repo}"
336 );
337 }
338 assert_eq!(
339 repo_path(&rp("ignition/tag-group/P/Default/resource.json")).unwrap(),
340 rp("tags/P/tag-group/Default/resource.json")
341 );
342 assert_eq!(
343 repo_path(&rp("ignition/database-translator/X/config.json")).unwrap(),
344 rp("resources/ignition/database-translator/X/config.json")
345 );
346 }
347
348 #[test]
349 fn repo_path_refuses_what_has_no_layout() {
350 assert!(repo_path(&rp("ignition/config.json")).is_err());
352 assert!(repo_path(&rp("config.json")).is_err());
353 assert!(repo_path(&rp("ignition/tag-definition/P")).is_err());
355 assert!(repo_path(&rp("ignition/tag-definition/x.json")).is_err());
356 assert!(repo_path(&rp("ignition/tag-other/P/x.json")).is_err());
358 }
359
360 #[test]
361 fn resource_key_names_only_with_a_fourth_component() {
362 let singleton = resource_key(&rp("ignition/general-alarm-settings/config.json"));
363 assert_eq!(singleton.name, None);
364 let named = resource_key(&rp("ignition/database-translator/POSTGRES/config.json"));
365 assert_eq!(named.module, "ignition");
366 assert_eq!(named.ty, "database-translator");
367 assert_eq!(named.name.as_deref(), Some("POSTGRES"));
368 }
369
370 #[test]
371 fn singleton_key_orders_before_named_of_same_type() {
372 let a = ResourceKey {
373 module: "m".into(),
374 ty: "t".into(),
375 name: None,
376 };
377 let b = ResourceKey {
378 module: "m".into(),
379 ty: "t".into(),
380 name: Some("A".into()),
381 };
382 assert!(a < b);
383 }
384}