systemprompt_identifiers/
path.rs1use crate::error::IdValidationError;
7use crate::{DbValue, ToDbValue};
8use serde::{Deserialize, Serialize};
9use std::fmt;
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
12#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
13#[cfg_attr(feature = "sqlx", sqlx(transparent))]
14#[serde(transparent)]
15pub struct ValidatedFilePath(String);
16
17impl ValidatedFilePath {
18 pub fn try_new(value: impl Into<String>) -> Result<Self, IdValidationError> {
19 let value = value.into();
20 if value.is_empty() {
21 return Err(IdValidationError::empty("ValidatedFilePath"));
22 }
23 if value.contains('\0') {
24 return Err(IdValidationError::invalid(
25 "ValidatedFilePath",
26 "cannot contain null bytes",
27 ));
28 }
29 for component in value.split(['/', '\\']) {
30 if component == ".." {
31 return Err(IdValidationError::invalid(
32 "ValidatedFilePath",
33 "cannot contain '..' path traversal",
34 ));
35 }
36 let lower = component.to_lowercase();
37 if lower.contains("%2e%2e") || lower.contains("%2e.") || lower.contains(".%2e") {
38 return Err(IdValidationError::invalid(
39 "ValidatedFilePath",
40 "cannot contain encoded path traversal sequences",
41 ));
42 }
43 }
44 let lower_value = value.to_lowercase();
45 if lower_value.contains("%252e") {
46 return Err(IdValidationError::invalid(
47 "ValidatedFilePath",
48 "cannot contain double-encoded path sequences",
49 ));
50 }
51 Ok(Self(value))
52 }
53
54 #[must_use]
55 #[expect(
56 clippy::expect_used,
57 reason = "infallible constructor reserved for already-validated inputs; untrusted input \
58 must go through try_new"
59 )]
60 pub fn new(value: impl Into<String>) -> Self {
61 Self::try_new(value).expect("ValidatedFilePath validation failed")
62 }
63
64 #[must_use]
65 pub fn as_str(&self) -> &str {
66 &self.0
67 }
68
69 #[must_use]
70 pub fn extension(&self) -> Option<&str> {
71 self.0
72 .rsplit('.')
73 .next()
74 .filter(|_| self.0.contains('.') && !self.0.ends_with('.'))
75 }
76
77 #[must_use]
78 pub fn file_name(&self) -> Option<&str> {
79 self.0.rsplit(['/', '\\']).next().filter(|s| !s.is_empty())
80 }
81}
82
83impl fmt::Display for ValidatedFilePath {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 write!(f, "{}", self.0)
86 }
87}
88
89impl TryFrom<String> for ValidatedFilePath {
90 type Error = IdValidationError;
91
92 fn try_from(s: String) -> Result<Self, Self::Error> {
93 Self::try_new(s)
94 }
95}
96
97impl TryFrom<&str> for ValidatedFilePath {
98 type Error = IdValidationError;
99
100 fn try_from(s: &str) -> Result<Self, Self::Error> {
101 Self::try_new(s)
102 }
103}
104
105impl std::str::FromStr for ValidatedFilePath {
106 type Err = IdValidationError;
107
108 fn from_str(s: &str) -> Result<Self, Self::Err> {
109 Self::try_new(s)
110 }
111}
112
113impl AsRef<str> for ValidatedFilePath {
114 fn as_ref(&self) -> &str {
115 &self.0
116 }
117}
118
119impl<'de> Deserialize<'de> for ValidatedFilePath {
120 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
121 where
122 D: serde::Deserializer<'de>,
123 {
124 let s = String::deserialize(deserializer)?;
125 Self::try_new(s).map_err(serde::de::Error::custom)
126 }
127}
128
129impl ToDbValue for ValidatedFilePath {
130 fn to_db_value(&self) -> DbValue {
131 DbValue::String(self.0.clone())
132 }
133}
134
135impl ToDbValue for &ValidatedFilePath {
136 fn to_db_value(&self) -> DbValue {
137 DbValue::String(self.0.clone())
138 }
139}