Skip to main content

agentics_domain/models/
paths.rs

1//! Validated path-like types used at API and repository boundaries.
2
3use std::borrow::Cow;
4use std::fmt;
5use std::path::{Component, Path, PathBuf};
6use std::str::FromStr;
7
8use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10
11use agentics_error::{Result, ServiceError};
12
13/// User-facing validation message for repository-relative paths.
14pub const REPO_RELATIVE_PATH_ERROR_MESSAGE: &str =
15    "repo-relative paths must be non-empty safe relative paths with ASCII components";
16
17/// Path relative to a challenge repository checkout.
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
19pub struct RepoRelativePath(String);
20
21impl RepoRelativePath {
22    /// Parse and validate a repository-relative path.
23    pub fn try_new(value: impl AsRef<str>) -> Result<Self> {
24        validate_relative_path(value.as_ref()).map(Self)
25    }
26
27    /// Borrow the path as a string using `/` separators.
28    pub fn as_str(&self) -> &str {
29        &self.0
30    }
31
32    /// Borrow the path for filesystem joins.
33    pub fn as_path(&self) -> &Path {
34        Path::new(&self.0)
35    }
36}
37
38impl fmt::Display for RepoRelativePath {
39    /// Handles fmt for this module.
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        f.write_str(self.as_str())
42    }
43}
44
45impl AsRef<str> for RepoRelativePath {
46    /// Returns ref in the representation required by callers.
47    fn as_ref(&self) -> &str {
48        self.as_str()
49    }
50}
51
52impl AsRef<Path> for RepoRelativePath {
53    /// Returns ref in the representation required by callers.
54    fn as_ref(&self) -> &Path {
55        self.as_path()
56    }
57}
58
59impl FromStr for RepoRelativePath {
60    type Err = ServiceError;
61
62    /// Handles from str for this module.
63    fn from_str(value: &str) -> Result<Self> {
64        Self::try_new(value)
65    }
66}
67
68impl Serialize for RepoRelativePath {
69    /// Handles serialize for this module.
70    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
71    where
72        S: Serializer,
73    {
74        serializer.serialize_str(self.as_str())
75    }
76}
77
78impl<'de> Deserialize<'de> for RepoRelativePath {
79    /// Handles deserialize for this module.
80    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
81    where
82        D: Deserializer<'de>,
83    {
84        let value = String::deserialize(deserializer)?;
85        Self::try_new(&value).map_err(serde::de::Error::custom)
86    }
87}
88
89impl JsonSchema for RepoRelativePath {
90    /// Handles inline schema for this module.
91    fn inline_schema() -> bool {
92        true
93    }
94
95    /// Handles schema name for this module.
96    fn schema_name() -> Cow<'static, str> {
97        "RepoRelativePath".into()
98    }
99
100    /// Handles json schema for this module.
101    fn json_schema(_: &mut SchemaGenerator) -> Schema {
102        json_schema!({
103            "type": "string",
104            "pattern": r"^(?!.*(?:^|/)\.{1,2}(?:/|$))[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$"
105        })
106    }
107}
108
109macro_rules! define_relative_path_type {
110    ($type_name:ident, $schema_name:literal) => {
111        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
112        pub struct $type_name(String);
113
114        impl $type_name {
115            /// Parse and validate a safe relative path.
116            pub fn try_new(value: impl AsRef<str>) -> Result<Self> {
117                validate_relative_path(value.as_ref()).map(Self)
118            }
119
120            /// Borrow the path as a string using `/` separators.
121            pub fn as_str(&self) -> &str {
122                &self.0
123            }
124
125            /// Borrow the path for filesystem joins.
126            pub fn as_path(&self) -> &Path {
127                Path::new(&self.0)
128            }
129        }
130
131        impl fmt::Display for $type_name {
132            /// Handles fmt for this module.
133            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134                f.write_str(self.as_str())
135            }
136        }
137
138        impl AsRef<str> for $type_name {
139            /// Returns ref in the representation required by callers.
140            fn as_ref(&self) -> &str {
141                self.as_str()
142            }
143        }
144
145        impl AsRef<Path> for $type_name {
146            /// Returns ref in the representation required by callers.
147            fn as_ref(&self) -> &Path {
148                self.as_path()
149            }
150        }
151
152        impl FromStr for $type_name {
153            type Err = ServiceError;
154
155            /// Handles from str for this module.
156            fn from_str(value: &str) -> Result<Self> {
157                Self::try_new(value)
158            }
159        }
160
161        impl Serialize for $type_name {
162            /// Handles serialize for this module.
163            fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
164            where
165                S: Serializer,
166            {
167                serializer.serialize_str(self.as_str())
168            }
169        }
170
171        impl<'de> Deserialize<'de> for $type_name {
172            /// Handles deserialize for this module.
173            fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
174            where
175                D: Deserializer<'de>,
176            {
177                let value = String::deserialize(deserializer)?;
178                Self::try_new(&value).map_err(serde::de::Error::custom)
179            }
180        }
181
182        impl JsonSchema for $type_name {
183            /// Handles inline schema for this module.
184            fn inline_schema() -> bool {
185                true
186            }
187
188            /// Handles schema name for this module.
189            fn schema_name() -> Cow<'static, str> {
190                $schema_name.into()
191            }
192
193            /// Handles json schema for this module.
194            fn json_schema(_: &mut SchemaGenerator) -> Schema {
195                json_schema!({
196                    "type": "string",
197                    "pattern": r"^(?!.*(?:^|/)\.{1,2}(?:/|$))[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$"
198                })
199            }
200        }
201    };
202}
203
204define_relative_path_type!(BundleRelativePath, "BundleRelativePath");
205define_relative_path_type!(RunInputPath, "RunInputPath");
206define_relative_path_type!(RunOutputPath, "RunOutputPath");
207define_relative_path_type!(ProjectRelativePath, "ProjectRelativePath");
208define_relative_path_type!(ScriptPath, "ScriptPath");
209define_relative_path_type!(LogRelativePath, "LogRelativePath");
210
211/// Canonical server-local repository checkout path.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct RepositoryCheckoutPath(PathBuf);
214
215impl RepositoryCheckoutPath {
216    /// Canonicalize and verify an existing repository checkout directory.
217    pub fn from_existing_dir(path: impl AsRef<str>) -> Result<Self> {
218        canonical_existing_dir(path.as_ref(), "repository_path").map(Self)
219    }
220
221    /// Borrow the canonical filesystem path.
222    pub fn as_path(&self) -> &Path {
223        &self.0
224    }
225}
226
227impl fmt::Display for RepositoryCheckoutPath {
228    /// Handles fmt for this module.
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        write!(f, "{}", self.0.display())
231    }
232}
233
234/// Canonical server-local admin bundle path.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct AdminBundlePath(PathBuf);
237
238impl AdminBundlePath {
239    /// Canonicalize and verify an existing admin bundle directory.
240    pub fn from_existing_dir(path: impl AsRef<Path>) -> Result<Self> {
241        canonical_existing_dir_path(path.as_ref(), "bundle_path").map(Self)
242    }
243
244    /// Borrow the canonical filesystem path.
245    pub fn as_path(&self) -> &Path {
246        &self.0
247    }
248}
249
250impl fmt::Display for AdminBundlePath {
251    /// Handles fmt for this module.
252    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253        write!(f, "{}", self.0.display())
254    }
255}
256
257macro_rules! define_managed_path_type {
258    ($type_name:ident, $schema_name:literal, $constructor:ident, $validator:ident, $field:literal) => {
259        #[derive(Debug, Clone, PartialEq, Eq)]
260        pub struct $type_name(PathBuf);
261
262        impl $type_name {
263            /// Canonicalize and verify a managed platform filesystem path.
264            pub fn $constructor(path: impl AsRef<Path>) -> Result<Self> {
265                $validator(path.as_ref(), $field).map(Self)
266            }
267
268            /// Borrow the canonical filesystem path.
269            pub fn as_path(&self) -> &Path {
270                &self.0
271            }
272
273            /// Borrow the canonical filesystem path as UTF-8 for storage.
274            pub fn as_str(&self) -> Result<&str> {
275                self.0.to_str().ok_or_else(|| {
276                    ServiceError::Internal(format!("{} is not valid UTF-8", $field))
277                })
278            }
279        }
280
281        impl fmt::Display for $type_name {
282            /// Handles fmt for this module.
283            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284                write!(f, "{}", self.0.display())
285            }
286        }
287
288        impl Serialize for $type_name {
289            /// Handles serialize for this module.
290            fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
291            where
292                S: Serializer,
293            {
294                let value = self
295                    .0
296                    .to_str()
297                    .ok_or_else(|| serde::ser::Error::custom(format!("{} is not valid UTF-8", $field)))?;
298                serializer.serialize_str(value)
299            }
300        }
301
302        impl<'de> Deserialize<'de> for $type_name {
303            /// Handles deserialize for this module.
304            fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
305            where
306                D: Deserializer<'de>,
307            {
308                let value = String::deserialize(deserializer)?;
309                Self::$constructor(Path::new(&value)).map_err(serde::de::Error::custom)
310            }
311        }
312
313        impl JsonSchema for $type_name {
314            /// Handles inline schema for this module.
315            fn inline_schema() -> bool {
316                true
317            }
318
319            /// Handles schema name for this module.
320            fn schema_name() -> Cow<'static, str> {
321                $schema_name.into()
322            }
323
324            /// Handles json schema for this module.
325            fn json_schema(_: &mut SchemaGenerator) -> Schema {
326                json_schema!({ "type": "string" })
327            }
328        }
329    };
330}
331
332define_managed_path_type!(
333    ManagedBundlePath,
334    "ManagedBundlePath",
335    from_existing_dir,
336    canonical_existing_dir_path,
337    "managed bundle path"
338);
339define_managed_path_type!(
340    ManagedStatementPath,
341    "ManagedStatementPath",
342    from_existing_file,
343    canonical_existing_file_path,
344    "managed statement path"
345);
346
347/// Handles canonical existing dir for this module.
348fn canonical_existing_dir(value: &str, field: &str) -> Result<PathBuf> {
349    let value = value.trim();
350    if value.is_empty() || value.chars().any(|c| c.is_control()) {
351        return Err(ServiceError::BadRequest(format!(
352            "{field} must be a valid directory path"
353        )));
354    }
355    canonical_existing_dir_path(Path::new(value), field)
356}
357
358/// Handles canonical existing dir path for this module.
359fn canonical_existing_dir_path(path: &Path, field: &str) -> Result<PathBuf> {
360    let canonical = std::fs::canonicalize(path).map_err(|e| {
361        ServiceError::BadRequest(format!("{field} does not exist or cannot be resolved: {e}"))
362    })?;
363    let metadata = std::fs::metadata(&canonical)
364        .map_err(|e| ServiceError::BadRequest(format!("{field} cannot be inspected: {e}")))?;
365    if !metadata.is_dir() {
366        return Err(ServiceError::BadRequest(format!(
367            "{field} must be a directory"
368        )));
369    }
370    Ok(canonical)
371}
372
373/// Handles canonical existing file path for this module.
374fn canonical_existing_file_path(path: &Path, field: &str) -> Result<PathBuf> {
375    let canonical = std::fs::canonicalize(path).map_err(|e| {
376        ServiceError::BadRequest(format!("{field} does not exist or cannot be resolved: {e}"))
377    })?;
378    let metadata = std::fs::metadata(&canonical)
379        .map_err(|e| ServiceError::BadRequest(format!("{field} cannot be inspected: {e}")))?;
380    if !metadata.is_file() {
381        return Err(ServiceError::BadRequest(format!("{field} must be a file")));
382    }
383    Ok(canonical)
384}
385
386/// Validates relative path invariants for this contract.
387fn validate_relative_path(value: &str) -> Result<String> {
388    if value.is_empty()
389        || value.trim() != value
390        || value.starts_with('/')
391        || value.ends_with('/')
392        || value.contains('\\')
393        || value
394            .bytes()
395            .any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control())
396    {
397        return Err(ServiceError::BadRequest(
398            REPO_RELATIVE_PATH_ERROR_MESSAGE.to_string(),
399        ));
400    }
401    let path = Path::new(value);
402    if path.is_absolute() {
403        return Err(ServiceError::BadRequest(
404            REPO_RELATIVE_PATH_ERROR_MESSAGE.to_string(),
405        ));
406    }
407
408    let mut parts = Vec::new();
409    for component in path.components() {
410        match component {
411            Component::Normal(part) => {
412                let Some(part) = part.to_str() else {
413                    return Err(ServiceError::BadRequest(
414                        REPO_RELATIVE_PATH_ERROR_MESSAGE.to_string(),
415                    ));
416                };
417                if part.is_empty()
418                    || !part.bytes().all(|byte| {
419                        byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')
420                    })
421                {
422                    return Err(ServiceError::BadRequest(
423                        REPO_RELATIVE_PATH_ERROR_MESSAGE.to_string(),
424                    ));
425                }
426                parts.push(part);
427            }
428            _ => {
429                return Err(ServiceError::BadRequest(
430                    REPO_RELATIVE_PATH_ERROR_MESSAGE.to_string(),
431                ));
432            }
433        }
434    }
435    if parts.is_empty() || parts.join("/") != value {
436        return Err(ServiceError::BadRequest(
437            REPO_RELATIVE_PATH_ERROR_MESSAGE.to_string(),
438        ));
439    }
440    Ok(value.to_string())
441}
442
443#[cfg(test)]
444mod tests {
445    use super::{
446        BundleRelativePath, LogRelativePath, ProjectRelativePath, RepoRelativePath, RunInputPath,
447        RunOutputPath, ScriptPath,
448    };
449
450    /// Verifies that validates repo relative paths.
451    #[test]
452    fn validates_repo_relative_paths() {
453        for value in ["README.md", "v1", "challenges/sample-sum"] {
454            assert!(RepoRelativePath::try_new(value).is_ok());
455        }
456        for value in ["", "/abs", "../escape", "a/../b", "a//b", "a b", "a\\b"] {
457            assert!(RepoRelativePath::try_new(value).is_err());
458        }
459    }
460
461    /// Verifies that validates manifest and runner relative paths.
462    #[test]
463    fn validates_manifest_and_runner_relative_paths() {
464        for value in [
465            "agentics.solution.json",
466            "public/runs.json",
467            "logs/build.txt",
468        ] {
469            assert!(BundleRelativePath::try_new(value).is_ok());
470            assert!(RunInputPath::try_new(value).is_ok());
471            assert!(RunOutputPath::try_new(value).is_ok());
472            assert!(ProjectRelativePath::try_new(value).is_ok());
473            assert!(ScriptPath::try_new(value).is_ok());
474            assert!(LogRelativePath::try_new(value).is_ok());
475        }
476        for value in [
477            "",
478            "/abs",
479            "../escape",
480            "a/../b",
481            "a//b",
482            "a b",
483            "a\\b",
484            "a/\nb",
485        ] {
486            assert!(BundleRelativePath::try_new(value).is_err());
487            assert!(RunInputPath::try_new(value).is_err());
488            assert!(RunOutputPath::try_new(value).is_err());
489            assert!(ProjectRelativePath::try_new(value).is_err());
490            assert!(ScriptPath::try_new(value).is_err());
491            assert!(LogRelativePath::try_new(value).is_err());
492        }
493    }
494}