Skip to main content

systemprompt_identifiers/
path.rs

1//! Validated file path type.
2
3use 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        // SAFETY: `new` is the infallible constructor reserved for inputs the caller
59        // has already validated (compile-time literals, values that
60        // round-tripped through `try_new` at a boundary). Untrusted input must
61        // go through `try_new`.
62        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}