#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
pub struct RelPath(String);
impl RelPath {
pub fn new(s: &str) -> Result<RelPath, String> {
if s.is_empty() {
return Err("path must not be empty".to_string());
}
if s.starts_with('/') {
return Err("path must not start with `/`".to_string());
}
if s.contains('\\') {
return Err("path must not contain a backslash".to_string());
}
if s.contains('\0') {
return Err("path must not contain a NUL byte".to_string());
}
for component in s.split('/') {
match component {
"" => return Err("path must not contain an empty component".to_string()),
"." | ".." => return Err("path must not contain `.` or `..`".to_string()),
_ => {}
}
}
Ok(RelPath(s.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn components(&self) -> std::str::Split<'_, char> {
self.0.split('/')
}
pub fn strip_prefix(&self, prefix: &str) -> Option<RelPath> {
let rest = self.0.strip_prefix(prefix)?.strip_prefix('/')?;
RelPath::new(rest).ok()
}
}
impl std::fmt::Display for RelPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ResourceKey {
pub module: String,
pub ty: String,
pub name: Option<String>,
}
pub const TAG_TYPES: [&str; 4] = [
"tag-provider",
"tag-group",
"tag-type-definition",
"tag-definition",
];
pub fn collection_path(repo_rel: &RelPath) -> Result<RelPath, String> {
if let Some(rest) = repo_rel.strip_prefix("tags") {
return tag_collection_path(&rest);
}
let rest = repo_rel
.strip_prefix("resources")
.ok_or_else(|| "not under `resources/` or `tags/`".to_string())?;
if rest.components().count() < 3 {
return Err(
"a resource file must sit at `resources/<module>/<type>/<file>` or deeper".to_string(),
);
}
Ok(rest)
}
pub fn repo_path(collection: &RelPath) -> Result<RelPath, String> {
let parts: Vec<&str> = collection.components().collect();
if parts.len() < 3 {
return Err(
"a resource file must sit at `<module>/<type>/<file>` or deeper in a collection"
.to_string(),
);
}
if parts[0] == "ignition" {
let ty = parts[1];
if TAG_TYPES.contains(&ty) {
if parts.len() < 4 {
return Err(
"a tag file must sit at `ignition/<tag type>/<provider>/<file>` or deeper"
.to_string(),
);
}
return RelPath::new(&format!("tags/{}/{ty}/{}", parts[2], parts[3..].join("/")));
}
if ty.starts_with("tag-") {
return Err(format!(
"an `ignition/tag-*` type that is not one of the tag types ({})",
TAG_TYPES.join(", ")
));
}
}
RelPath::new(&format!("resources/{collection}"))
}
fn tag_collection_path(rest: &RelPath) -> Result<RelPath, String> {
let parts: Vec<&str> = rest.components().collect();
if parts.len() < 3 {
return Err("a tag file must sit at `tags/<provider>/<type>/<file>` or deeper".to_string());
}
let (provider, ty, tail) = (parts[0], parts[1], &parts[2..]);
if !TAG_TYPES.contains(&ty) {
return Err(format!(
"`{ty}` is not a tag type (expected one of: {})",
TAG_TYPES.join(", ")
));
}
RelPath::new(&format!("ignition/{ty}/{provider}/{}", tail.join("/")))
}
pub fn tag_provider(repo_rel: &RelPath) -> Option<&str> {
let mut parts = repo_rel.components();
if parts.next() != Some("tags") {
return None;
}
parts.next()
}
pub fn resource_key(collection: &RelPath) -> ResourceKey {
let parts: Vec<&str> = collection.components().collect();
ResourceKey {
module: parts.first().copied().unwrap_or_default().to_string(),
ty: parts.get(1).copied().unwrap_or_default().to_string(),
name: if parts.len() >= 4 {
Some(parts[2].to_string())
} else {
None
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rp(s: &str) -> RelPath {
RelPath::new(s).unwrap()
}
#[test]
fn valid_paths_round_trip() {
for ok in ["a", "a/b", "résumé/x", "A/a", "a b/c.json"] {
assert_eq!(RelPath::new(ok).unwrap().as_str(), ok);
}
}
#[test]
fn hostile_paths_are_refused() {
for bad in [
"", "/abs", "a\\b", "a//b", "a/", "./a", "a/./b", "a/..", "../a", "a\0b",
] {
assert!(RelPath::new(bad).is_err(), "{bad:?}");
}
}
#[test]
fn ordering_is_utf8_byte_order() {
let mut v = [rp("b"), rp("a"), rp("B"), rp("é"), rp("Z")];
v.sort();
let got: Vec<&str> = v.iter().map(RelPath::as_str).collect();
assert_eq!(got, ["B", "Z", "a", "b", "é"]);
}
#[test]
fn strip_prefix_needs_a_whole_component() {
assert_eq!(
rp("resources/a/b").strip_prefix("resources"),
Some(rp("a/b"))
);
assert_eq!(rp("resourcesx/a").strip_prefix("resources"), None);
assert_eq!(rp("resources").strip_prefix("resources"), None);
}
#[test]
fn collection_path_requires_three_components() {
assert_eq!(
collection_path(&rp("resources/ignition/x/config.json")).unwrap(),
rp("ignition/x/config.json")
);
assert!(collection_path(&rp("resources/config.json")).is_err());
assert!(collection_path(&rp("resources/ignition/config.json")).is_err());
assert!(collection_path(&rp("other/ignition/x/y")).is_err());
}
#[test]
fn tags_map_per_cd5() {
let cases = [
(
"tags/P/tag-provider/config.json",
"ignition/tag-provider/P/config.json",
),
(
"tags/P/tag-group/Default/resource.json",
"ignition/tag-group/P/Default/resource.json",
),
(
"tags/P/tag-type-definition/udts.json",
"ignition/tag-type-definition/P/udts.json",
),
(
"tags/P/tag-definition/unary-resource.json",
"ignition/tag-definition/P/unary-resource.json",
),
(
"tags/P/tag-definition/FolderA/Sub/tags.json",
"ignition/tag-definition/P/FolderA/Sub/tags.json",
),
(
"tags/P/tag-definition/tags.json",
"ignition/tag-definition/P/tags.json",
),
(
"tags/P/tag-definition/udts.json",
"ignition/tag-definition/P/udts.json",
),
(
"tags/P/tag-type-definition/A/B/udts.json",
"ignition/tag-type-definition/P/A/B/udts.json",
),
(
"tags/P/tag-type-definition/A/B/unary-resource.json",
"ignition/tag-type-definition/P/A/B/unary-resource.json",
),
(
"tags/P/tag-type-definition/A/unary-resource.json",
"ignition/tag-type-definition/P/A/unary-resource.json",
),
(
"tags/P/tag-type-definition/_types_/udts.json",
"ignition/tag-type-definition/P/_types_/udts.json",
),
];
for (from, to) in cases {
assert_eq!(collection_path(&rp(from)).unwrap(), rp(to), "{from}");
}
assert_eq!(tag_provider(&rp("tags/P/tag-group/x")), Some("P"));
assert_eq!(tag_provider(&rp("resources/a/b/c")), None);
}
#[test]
fn unmapped_tag_layouts_stay_fail_closed() {
for bad in [
"tags/P/other/x.json",
"tags/P/x.json",
"tags/x.json",
"tags/P/tag-definition",
] {
assert!(collection_path(&rp(bad)).is_err(), "{bad}");
}
}
#[test]
fn repo_path_inverts_collection_path() {
let cases = [
"ignition/general-alarm-settings/config.json",
"ignition/database-translator/POSTGRES/config.json",
"ignition/database-translator/POSTGRES/resource.json",
"com.example.mod/thing/A B/x/y.json",
"ignition/tag-provider/P/config.json",
"ignition/tag-group/P/Default/resource.json",
"ignition/tag-type-definition/P/udts.json",
"ignition/tag-definition/P/unary-resource.json",
"ignition/tag-definition/P/FolderA/Sub/tags.json",
"ignition/tag-definition/P/tags.json",
"ignition/tag-definition/P/udts.json",
"ignition/tag-type-definition/P/A/B/udts.json",
"ignition/tag-type-definition/P/A/B/unary-resource.json",
"ignition/tag-type-definition/P/A/unary-resource.json",
];
for collection in cases {
let c = rp(collection);
let repo = repo_path(&c).unwrap_or_else(|e| panic!("{collection}: {e}"));
assert_eq!(
collection_path(&repo).unwrap(),
c,
"{collection} via {repo}"
);
}
assert_eq!(
repo_path(&rp("ignition/tag-group/P/Default/resource.json")).unwrap(),
rp("tags/P/tag-group/Default/resource.json")
);
assert_eq!(
repo_path(&rp("ignition/database-translator/X/config.json")).unwrap(),
rp("resources/ignition/database-translator/X/config.json")
);
}
#[test]
fn repo_path_refuses_what_has_no_layout() {
assert!(repo_path(&rp("ignition/config.json")).is_err());
assert!(repo_path(&rp("config.json")).is_err());
assert!(repo_path(&rp("ignition/tag-definition/P")).is_err());
assert!(repo_path(&rp("ignition/tag-definition/x.json")).is_err());
assert!(repo_path(&rp("ignition/tag-other/P/x.json")).is_err());
}
use proptest::prelude::*;
proptest! {
#[test]
fn cd5_tag_paths_round_trip(
provider in "[A-Za-z0-9_]{1,12}",
ty in prop::sample::select(TAG_TYPES.to_vec()),
tail in prop::collection::vec("[A-Za-z0-9_]{1,12}", 1..=6),
) {
let repo = rp(&format!("tags/{provider}/{ty}/{}", tail.join("/")));
let collection = collection_path(&repo).expect("every tag layout maps");
prop_assert_eq!(
collection.as_str(),
format!("ignition/{ty}/{provider}/{}", tail.join("/"))
);
prop_assert_eq!(&repo_path(&collection).expect("inverse"), &repo);
prop_assert_eq!(&collection_path(&repo_path(&collection).unwrap()).unwrap(), &collection);
}
}
#[test]
fn resource_key_names_only_with_a_fourth_component() {
let singleton = resource_key(&rp("ignition/general-alarm-settings/config.json"));
assert_eq!(singleton.name, None);
let named = resource_key(&rp("ignition/database-translator/POSTGRES/config.json"));
assert_eq!(named.module, "ignition");
assert_eq!(named.ty, "database-translator");
assert_eq!(named.name.as_deref(), Some("POSTGRES"));
}
#[test]
fn singleton_key_orders_before_named_of_same_type() {
let a = ResourceKey {
module: "m".into(),
ty: "t".into(),
name: None,
};
let b = ResourceKey {
module: "m".into(),
ty: "t".into(),
name: Some("A".into()),
};
assert!(a < b);
}
}