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 validate_destination_path(value: &str) -> PrayResult<ProjectRelativePath> {
61    if value.trim().starts_with('~') {
62        return Err(PrayError::Manifest(format!(
63            "project path must be repository-relative: {}",
64            value.trim()
65        )));
66    }
67    ProjectRelativePath::parse(value)
68}
69
70pub fn find_prayspec_file(root: &Path) -> PrayResult<PathBuf> {
71    let mut prayspec_files = Vec::new();
72    for entry in fs::read_dir(root)? {
73        let entry = entry?;
74        let path = entry.path();
75        if path.extension().and_then(|value| value.to_str()) == Some("prayspec") {
76            prayspec_files.push(path);
77        }
78    }
79    match prayspec_files.len() {
80        1 => Ok(prayspec_files.remove(0)),
81        0 => Err(PrayError::Resolution(format!(
82            "no prayspec file found in {:?}",
83            root
84        ))),
85        _ => Err(PrayError::Resolution(format!(
86            "multiple prayspec files found in {:?}",
87            root
88        ))),
89    }
90}
91
92pub fn validate_package_relative_path(path: &Path) -> PrayResult<()> {
93    normalize_package_relative_path(path).map(|_| ())
94}
95
96pub fn normalize_package_relative_path(path: &Path) -> PrayResult<PathBuf> {
97    let text = path.to_string_lossy().replace('\\', "/");
98    if Path::new(&text).is_absolute() {
99        return Err(PrayError::Integrity(format!(
100            "package path must be relative: {}",
101            path.display()
102        )));
103    }
104    let mut relative = PathBuf::new();
105    for component in Path::new(&text).components() {
106        match component {
107            Component::Normal(part) => {
108                if part.to_string_lossy().contains('\0') {
109                    return Err(PrayError::Integrity(format!(
110                        "package path escapes package root: {}",
111                        path.display()
112                    )));
113                }
114                relative.push(part);
115            }
116            Component::CurDir => {}
117            _ => {
118                return Err(PrayError::Integrity(format!(
119                    "package path escapes package root: {}",
120                    path.display()
121                )));
122            }
123        }
124    }
125    if relative.as_os_str().is_empty() {
126        return Err(PrayError::Integrity(format!(
127            "package path must be relative: {}",
128            path.display()
129        )));
130    }
131    Ok(relative)
132}
133
134pub fn validate_registry_cache_identity<'a>(
135    package_name: &'a str,
136    version: &str,
137) -> PrayResult<(&'a str, &'a str)> {
138    let mut segments = package_name.split('/');
139    let namespace = segments.next().unwrap_or_default();
140    let name = segments.next().unwrap_or_default();
141    if segments.next().is_some()
142        || !registry_cache_segment_is_safe(namespace)
143        || !registry_cache_segment_is_safe(name)
144    {
145        return Err(PrayError::Integrity(format!(
146            "invalid registry package name: {package_name}"
147        )));
148    }
149    if !registry_cache_segment_is_safe(version) {
150        return Err(PrayError::Integrity(format!(
151            "invalid registry package version: {version}"
152        )));
153    }
154    Ok((namespace, name))
155}
156
157fn registry_cache_segment_is_safe(value: &str) -> bool {
158    !value.is_empty() && value != "." && value != ".." && !value.contains(['/', '\\', '\0'])
159}
160
161pub fn sanitize_relative_path(path: &str) -> PrayResult<PathBuf> {
162    let path = path.trim_start_matches('/');
163    let mut relative = PathBuf::new();
164    for component in Path::new(path).components() {
165        match component {
166            Component::Normal(part) => relative.push(part),
167            Component::CurDir => {}
168            _ => {
169                return Err(PrayError::Resolution(format!(
170                    "invalid relative path: {path}"
171                )));
172            }
173        }
174    }
175    if relative.as_os_str().is_empty() {
176        return Err(PrayError::Resolution(format!(
177            "invalid relative path: {path}"
178        )));
179    }
180    Ok(relative)
181}
182
183pub fn remove_path_if_exists(path: &Path) -> PrayResult<()> {
184    match fs::symlink_metadata(path) {
185        Ok(metadata) if metadata.is_dir() => {
186            fs::remove_dir_all(path)?;
187            Ok(())
188        }
189        Ok(_) => {
190            fs::remove_file(path)?;
191            Ok(())
192        }
193        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
194        Err(error) => Err(error.into()),
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn sanitize_relative_path_rejects_parent_dir() {
204        let error = sanitize_relative_path("../escape.praypkg").expect_err("parent dir");
205        assert!(error.to_string().contains("invalid relative path"));
206    }
207
208    #[test]
209    fn sanitize_relative_path_accepts_nested_artifact() {
210        let path = sanitize_relative_path("v1/artifacts/sample/base/1.0.0/package.praypkg")
211            .expect("nested path");
212        assert_eq!(
213            path,
214            PathBuf::from("v1/artifacts/sample/base/1.0.0/package.praypkg")
215        );
216    }
217
218    #[test]
219    fn validate_package_relative_path_rejects_parent_escape() {
220        let error =
221            validate_package_relative_path(Path::new("../escape.md")).expect_err("parent escape");
222        assert!(error.to_string().contains("escapes package root"));
223    }
224
225    #[test]
226    fn validate_package_relative_path_rejects_absolute() {
227        let absolute = if cfg!(windows) {
228            Path::new(r"C:\escape.md")
229        } else {
230            Path::new("/escape.md")
231        };
232        let error = validate_package_relative_path(absolute).expect_err("absolute");
233        assert!(error.to_string().contains("must be relative"));
234    }
235
236    #[test]
237    fn validate_package_relative_path_accepts_nested_file() {
238        validate_package_relative_path(Path::new("exports/guidance.md")).expect("nested file");
239    }
240
241    #[test]
242    fn normalize_package_relative_path_collapses_current_dir() {
243        let normalized = normalize_package_relative_path(Path::new("exports/./guidance.md"))
244            .expect("nested current dir");
245        assert_eq!(normalized, PathBuf::from("exports/guidance.md"));
246        let dotted =
247            normalize_package_relative_path(Path::new("./README.md")).expect("leading dot");
248        assert_eq!(dotted, PathBuf::from("README.md"));
249    }
250
251    #[test]
252    fn project_relative_path_rejects_parent_and_absolute() {
253        let parent = ProjectRelativePath::parse("../escape.md").expect_err("parent");
254        assert!(parent.to_string().contains("escapes repository root"));
255        let absolute = if cfg!(windows) {
256            ProjectRelativePath::parse(r"C:\escape.md")
257        } else {
258            ProjectRelativePath::parse("/tmp/escape.md")
259        }
260        .expect_err("absolute");
261        assert!(absolute.to_string().contains("repository-relative"));
262        let tilde = validate_destination_path("~/.zshrc").expect_err("tilde");
263        assert!(tilde.to_string().contains("repository-relative"));
264    }
265
266    #[test]
267    fn project_relative_path_joins_under_root() {
268        let relative = ProjectRelativePath::parse("docs/AGENTS.md").expect("relative");
269        assert_eq!(
270            relative.join_root(Path::new("/repo")),
271            PathBuf::from("/repo/docs/AGENTS.md")
272        );
273    }
274}