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