1use 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
13pub const REPO_RELATIVE_PATH_ERROR_MESSAGE: &str =
15 "repo-relative paths must be non-empty safe relative paths with ASCII components";
16
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
19pub struct RepoRelativePath(String);
20
21impl RepoRelativePath {
22 pub fn try_new(value: impl AsRef<str>) -> Result<Self> {
24 validate_relative_path(value.as_ref()).map(Self)
25 }
26
27 pub fn as_str(&self) -> &str {
29 &self.0
30 }
31
32 pub fn as_path(&self) -> &Path {
34 Path::new(&self.0)
35 }
36}
37
38impl fmt::Display for RepoRelativePath {
39 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 fn as_ref(&self) -> &str {
48 self.as_str()
49 }
50}
51
52impl AsRef<Path> for RepoRelativePath {
53 fn as_ref(&self) -> &Path {
55 self.as_path()
56 }
57}
58
59impl FromStr for RepoRelativePath {
60 type Err = ServiceError;
61
62 fn from_str(value: &str) -> Result<Self> {
64 Self::try_new(value)
65 }
66}
67
68impl Serialize for RepoRelativePath {
69 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 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 fn inline_schema() -> bool {
92 true
93 }
94
95 fn schema_name() -> Cow<'static, str> {
97 "RepoRelativePath".into()
98 }
99
100 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 pub fn try_new(value: impl AsRef<str>) -> Result<Self> {
117 validate_relative_path(value.as_ref()).map(Self)
118 }
119
120 pub fn as_str(&self) -> &str {
122 &self.0
123 }
124
125 pub fn as_path(&self) -> &Path {
127 Path::new(&self.0)
128 }
129 }
130
131 impl fmt::Display for $type_name {
132 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 fn as_ref(&self) -> &str {
141 self.as_str()
142 }
143 }
144
145 impl AsRef<Path> for $type_name {
146 fn as_ref(&self) -> &Path {
148 self.as_path()
149 }
150 }
151
152 impl FromStr for $type_name {
153 type Err = ServiceError;
154
155 fn from_str(value: &str) -> Result<Self> {
157 Self::try_new(value)
158 }
159 }
160
161 impl Serialize for $type_name {
162 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 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 fn inline_schema() -> bool {
185 true
186 }
187
188 fn schema_name() -> Cow<'static, str> {
190 $schema_name.into()
191 }
192
193 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#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct RepositoryCheckoutPath(PathBuf);
214
215impl RepositoryCheckoutPath {
216 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 pub fn as_path(&self) -> &Path {
223 &self.0
224 }
225}
226
227impl fmt::Display for RepositoryCheckoutPath {
228 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230 write!(f, "{}", self.0.display())
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct AdminBundlePath(PathBuf);
237
238impl AdminBundlePath {
239 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 pub fn as_path(&self) -> &Path {
246 &self.0
247 }
248}
249
250impl fmt::Display for AdminBundlePath {
251 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 pub fn $constructor(path: impl AsRef<Path>) -> Result<Self> {
265 $validator(path.as_ref(), $field).map(Self)
266 }
267
268 pub fn as_path(&self) -> &Path {
270 &self.0
271 }
272
273 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 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 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 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 fn inline_schema() -> bool {
316 true
317 }
318
319 fn schema_name() -> Cow<'static, str> {
321 $schema_name.into()
322 }
323
324 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
347fn 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
358fn 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
373fn 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
386fn 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 #[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 #[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}