Skip to main content

pray_core/
paths.rs

1use crate::{PrayError, PrayResult};
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4
5/// Repository-relative path that cannot escape a project root when joined.
6#[derive(Debug, Clone, PartialEq, Eq, Hash)]
7pub struct ProjectRelativePath(PathBuf);
8
9impl ProjectRelativePath {
10    pub fn parse(value: &str) -> PrayResult<Self> {
11        let trimmed = value.trim();
12        if trimmed.is_empty() {
13            return Err(PrayError::Manifest(
14                "project path must not be empty".to_string(),
15            ));
16        }
17        let path = Path::new(trimmed);
18        if path.is_absolute() {
19            return Err(PrayError::Manifest(format!(
20                "project path must be repository-relative: {trimmed}"
21            )));
22        }
23        let mut relative = PathBuf::new();
24        for component in path.components() {
25            match component {
26                Component::Normal(part) => relative.push(part),
27                Component::CurDir => {}
28                _ => {
29                    return Err(PrayError::Manifest(format!(
30                        "project path escapes repository root: {trimmed}"
31                    )));
32                }
33            }
34        }
35        if relative.as_os_str().is_empty() {
36            return Err(PrayError::Manifest(format!(
37                "project path must be repository-relative: {trimmed}"
38            )));
39        }
40        Ok(Self(relative))
41    }
42
43    pub fn as_path(&self) -> &Path {
44        &self.0
45    }
46
47    pub fn as_str(&self) -> &str {
48        self.0.to_str().unwrap_or("")
49    }
50
51    pub fn join_root(&self, root: &Path) -> PathBuf {
52        root.join(&self.0)
53    }
54}
55
56pub fn validate_project_relative_path(value: &str) -> PrayResult<ProjectRelativePath> {
57    ProjectRelativePath::parse(value)
58}
59
60pub fn find_prayspec_file(root: &Path) -> PrayResult<PathBuf> {
61    let mut prayspec_files = Vec::new();
62    for entry in fs::read_dir(root)? {
63        let entry = entry?;
64        let path = entry.path();
65        if path.extension().and_then(|value| value.to_str()) == Some("prayspec") {
66            prayspec_files.push(path);
67        }
68    }
69    match prayspec_files.len() {
70        1 => Ok(prayspec_files.remove(0)),
71        0 => Err(PrayError::Resolution(format!(
72            "no prayspec file found in {:?}",
73            root
74        ))),
75        _ => Err(PrayError::Resolution(format!(
76            "multiple prayspec files found in {:?}",
77            root
78        ))),
79    }
80}
81
82pub fn validate_package_relative_path(path: &Path) -> PrayResult<()> {
83    if path.is_absolute() {
84        return Err(PrayError::Integrity(format!(
85            "package path must be relative: {}",
86            path.display()
87        )));
88    }
89    for component in path.components() {
90        match component {
91            Component::Normal(_) | Component::CurDir => {}
92            _ => {
93                return Err(PrayError::Integrity(format!(
94                    "package path escapes package root: {}",
95                    path.display()
96                )));
97            }
98        }
99    }
100    Ok(())
101}
102
103pub fn sanitize_relative_path(path: &str) -> PrayResult<PathBuf> {
104    let path = path.trim_start_matches('/');
105    let mut relative = PathBuf::new();
106    for component in Path::new(path).components() {
107        match component {
108            Component::Normal(part) => relative.push(part),
109            Component::CurDir => {}
110            _ => {
111                return Err(PrayError::Resolution(format!(
112                    "invalid relative path: {path}"
113                )));
114            }
115        }
116    }
117    if relative.as_os_str().is_empty() {
118        return Err(PrayError::Resolution(format!(
119            "invalid relative path: {path}"
120        )));
121    }
122    Ok(relative)
123}
124
125pub fn remove_path_if_exists(path: &Path) -> PrayResult<()> {
126    match fs::symlink_metadata(path) {
127        Ok(metadata) if metadata.is_dir() => {
128            fs::remove_dir_all(path)?;
129            Ok(())
130        }
131        Ok(_) => {
132            fs::remove_file(path)?;
133            Ok(())
134        }
135        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
136        Err(error) => Err(error.into()),
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn sanitize_relative_path_rejects_parent_dir() {
146        let error = sanitize_relative_path("../escape.praypkg").expect_err("parent dir");
147        assert!(error.to_string().contains("invalid relative path"));
148    }
149
150    #[test]
151    fn sanitize_relative_path_accepts_nested_artifact() {
152        let path = sanitize_relative_path("v1/artifacts/sample/base/1.0.0/package.praypkg")
153            .expect("nested path");
154        assert_eq!(
155            path,
156            PathBuf::from("v1/artifacts/sample/base/1.0.0/package.praypkg")
157        );
158    }
159
160    #[test]
161    fn validate_package_relative_path_rejects_parent_escape() {
162        let error =
163            validate_package_relative_path(Path::new("../escape.md")).expect_err("parent escape");
164        assert!(error.to_string().contains("escapes package root"));
165    }
166
167    #[test]
168    fn validate_package_relative_path_rejects_absolute() {
169        let absolute = if cfg!(windows) {
170            Path::new(r"C:\escape.md")
171        } else {
172            Path::new("/escape.md")
173        };
174        let error = validate_package_relative_path(absolute).expect_err("absolute");
175        assert!(error.to_string().contains("must be relative"));
176    }
177
178    #[test]
179    fn validate_package_relative_path_accepts_nested_file() {
180        validate_package_relative_path(Path::new("exports/guidance.md")).expect("nested file");
181    }
182
183    #[test]
184    fn project_relative_path_rejects_parent_and_absolute() {
185        let parent = ProjectRelativePath::parse("../escape.md").expect_err("parent");
186        assert!(parent.to_string().contains("escapes repository root"));
187        let absolute = if cfg!(windows) {
188            ProjectRelativePath::parse(r"C:\escape.md")
189        } else {
190            ProjectRelativePath::parse("/tmp/escape.md")
191        }
192        .expect_err("absolute");
193        assert!(absolute.to_string().contains("repository-relative"));
194    }
195
196    #[test]
197    fn project_relative_path_joins_under_root() {
198        let relative = ProjectRelativePath::parse("docs/AGENTS.md").expect("relative");
199        assert_eq!(
200            relative.join_root(Path::new("/repo")),
201            PathBuf::from("/repo/docs/AGENTS.md")
202        );
203    }
204}