1use crate::{PrayError, PrayResult};
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4
5#[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}