1use std::path::{Component, Path, PathBuf};
4
5use rskit_errors::{AppError, AppResult};
6
7use crate::error::GitError;
8
9pub fn repo_relative_path(repo_root: &Path, path: &Path) -> AppResult<PathBuf> {
11 let repo_root = repo_root.canonicalize().map_err(|error| {
12 AppError::invalid_input(
13 "repo_root",
14 format!("failed to resolve git root '{}'", repo_root.display()),
15 )
16 .with_cause(error)
17 })?;
18 let path = path.canonicalize().map_err(|error| {
19 AppError::invalid_input(
20 "path",
21 format!("failed to resolve path '{}'", path.display()),
22 )
23 .with_cause(error)
24 })?;
25 path.strip_prefix(&repo_root)
26 .map(normalize_path)
27 .map_err(|error| {
28 AppError::invalid_input(
29 "path",
30 format!(
31 "path '{}' is not inside git root '{}'",
32 path.display(),
33 repo_root.display()
34 ),
35 )
36 .with_cause(error)
37 })
38}
39
40pub fn join_repo_path(prefix: &Path, relative: &Path) -> AppResult<PathBuf> {
42 validate_repo_relative("prefix", prefix)?;
43 validate_repo_relative("path", relative)?;
44 if prefix.as_os_str().is_empty() {
45 Ok(normalize_path(relative))
46 } else {
47 Ok(normalize_path(&prefix.join(relative)))
48 }
49}
50
51pub(crate) fn validate_repo_relative_path(path: &str) -> AppResult<()> {
52 rskit_fs::validate_relative_path(Path::new(path)).map_err(|error| {
53 AppError::from(GitError::InvalidPath {
54 path: path.to_string(),
55 })
56 .with_cause(error)
57 })
58}
59
60fn validate_repo_relative(field: &'static str, path: &Path) -> AppResult<()> {
61 rskit_fs::validate_relative_path(path).map_err(|error| {
62 AppError::invalid_input(
63 field,
64 format!("repository path {}: {error}", path.display()),
65 )
66 .with_cause(error)
67 })
68}
69
70fn normalize_path(path: &Path) -> PathBuf {
71 path.components()
72 .filter_map(|component| match component {
73 Component::Normal(value) => Some(PathBuf::from(value)),
74 Component::CurDir => None,
75 _ => Some(PathBuf::from(component.as_os_str())),
76 })
77 .fold(PathBuf::new(), |mut normalized, component| {
78 normalized.push(component);
79 normalized
80 })
81}
82
83#[cfg(test)]
84mod tests {
85 use std::fs;
86
87 use super::{join_repo_path, repo_relative_path};
88
89 #[test]
90 fn returns_path_relative_to_repo_root() {
91 let root = rskit_testutil::test_workspace!("git-repo-relative");
92 let repo = root.path().join("repo");
93 let workspace = repo.join("apps/web");
94 fs::create_dir_all(&workspace).expect("create workspace");
95
96 let relative = repo_relative_path(&repo, &workspace).expect("path is inside repo");
97
98 assert_eq!(relative, std::path::Path::new("apps/web"));
99 }
100
101 #[test]
102 fn joins_empty_prefix_as_relative_path() {
103 assert_eq!(
104 join_repo_path(std::path::Path::new(""), std::path::Path::new("src/lib.rs"))
105 .expect("path is safe"),
106 std::path::Path::new("src/lib.rs")
107 );
108 }
109
110 #[test]
111 fn rejects_absolute_relative_path() {
112 let error = join_repo_path(std::path::Path::new("repo"), std::path::Path::new("/tmp/x"))
113 .expect_err("absolute paths escape the repository prefix");
114
115 assert!(error.message().contains("path must be relative"));
116 }
117
118 #[test]
119 fn rejects_parent_traversal_path() {
120 let error = join_repo_path(std::path::Path::new("repo"), std::path::Path::new("../x"))
121 .expect_err("parent traversal escapes the repository prefix");
122
123 assert!(error.message().contains("must not contain '..'"));
124 }
125
126 #[test]
127 fn rejects_escaping_prefix() {
128 let error = join_repo_path(std::path::Path::new("../repo"), std::path::Path::new("x"))
129 .expect_err("prefix must also be repository relative");
130
131 assert!(error.message().contains("must not contain '..'"));
132 }
133
134 #[test]
135 fn returns_empty_relative_path_for_repo_root() {
136 let root = rskit_testutil::test_workspace!("git-repo-root-relative");
137 let repo = root.path().join("repo");
138 fs::create_dir_all(&repo).expect("create repo root");
139
140 let relative = repo_relative_path(&repo, &repo).expect("repo root is inside itself");
141
142 assert_eq!(relative, std::path::Path::new(""));
143 }
144
145 #[test]
146 fn rejects_missing_or_external_paths_with_actionable_errors() {
147 let root = rskit_testutil::test_workspace!("git-repo-relative-errors");
148 let repo = root.path().join("repo");
149 let outside = root.path().join("outside");
150 fs::create_dir_all(&repo).expect("create repo root");
151 fs::create_dir_all(&outside).expect("create outside dir");
152
153 let missing =
154 repo_relative_path(&repo, &repo.join("missing")).expect_err("missing path fails");
155 assert!(missing.message().contains("failed to resolve path"));
156
157 let external = repo_relative_path(&repo, &outside).expect_err("external path fails");
158 assert!(external.message().contains("is not inside git root"));
159 }
160}