use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct CompanionIdentifier(String);
impl CompanionIdentifier {
pub fn new(s: &str) -> anyhow::Result<Self> {
if s.is_empty() {
anyhow::bail!("companion identifier cannot be empty");
}
if s.starts_with('/') {
anyhow::bail!("companion identifier '{s}' must be relative, not absolute");
}
if s.split('/').any(|seg| seg == "..") {
anyhow::bail!("companion identifier '{s}' must not contain '..'");
}
Ok(Self(s.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for CompanionIdentifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[cfg(test)]
pub mod strategy {
use super::CompanionIdentifier;
use proptest::prelude::*;
pub fn companion_identifier() -> impl Strategy<Value = CompanionIdentifier> {
"[a-zA-Z][a-zA-Z0-9 _.-]{0,19}\\.[a-z]{1,4}"
.prop_filter("must not start with '..'", |s| !s.starts_with(".."))
.prop_map(|s| CompanionIdentifier::new(&s).unwrap())
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn as_str_roundtrips_through_new(id in strategy::companion_identifier()) {
let s = id.as_str().to_string();
let again = CompanionIdentifier::new(&s).unwrap();
prop_assert_eq!(again.as_str().to_string(), s);
}
}
#[test]
fn accepts_simple_filenames() {
assert_eq!(
CompanionIdentifier::new("plan.md").unwrap().as_str(),
"plan.md"
);
assert_eq!(
CompanionIdentifier::new("design-decision.md")
.unwrap()
.as_str(),
"design-decision.md"
);
}
#[test]
fn accepts_filenames_with_spaces_and_arbitrary_extensions() {
assert_eq!(
CompanionIdentifier::new("mockup UX.xml").unwrap().as_str(),
"mockup UX.xml"
);
assert_eq!(
CompanionIdentifier::new("screenshot.png").unwrap().as_str(),
"screenshot.png"
);
}
#[test]
fn accepts_nested_paths() {
assert_eq!(
CompanionIdentifier::new("img/screen.png").unwrap().as_str(),
"img/screen.png"
);
}
#[test]
fn rejects_empty() {
assert!(CompanionIdentifier::new("").is_err());
}
#[test]
fn rejects_absolute_paths() {
assert!(CompanionIdentifier::new("/plan.md").is_err());
}
#[test]
fn rejects_parent_escape() {
assert!(CompanionIdentifier::new("../leak.md").is_err());
assert!(CompanionIdentifier::new("img/../../leak.md").is_err());
}
}