Skip to main content

systemprompt_identifiers/
path.rs

1//! Validated file path type.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use 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}