ito_config/ito_dir/
mod.rs1use std::path::{Path, PathBuf};
7
8use ito_common::fs::{FileSystem, StdFs};
9
10use crate::{ConfigContext, load_global_config_fs, load_repo_project_path_override_fs};
11
12pub fn get_ito_dir_name(project_root: &Path, ctx: &ConfigContext) -> String {
17 get_ito_dir_name_fs(&StdFs, project_root, ctx)
18}
19
20pub fn get_ito_dir_name_fs<F: FileSystem>(
22 fs: &F,
23 project_root: &Path,
24 ctx: &ConfigContext,
25) -> String {
26 if let Some(project_path) = load_repo_project_path_override_fs(fs, project_root)
32 && let Some(project_path) = sanitize_ito_dir_name(&project_path)
33 {
34 return project_path;
35 }
36
37 if let Some(project_path) = load_global_config_fs(fs, ctx).project_path
38 && let Some(project_path) = sanitize_ito_dir_name(&project_path)
39 {
40 return project_path;
41 }
42
43 ".ito".to_string()
44}
45
46pub fn get_ito_path(project_root: &Path, ctx: &ConfigContext) -> PathBuf {
48 get_ito_path_fs(&StdFs, project_root, ctx)
49}
50
51pub fn get_ito_path_fs<F: FileSystem>(fs: &F, project_root: &Path, ctx: &ConfigContext) -> PathBuf {
71 let root = absolutize_and_normalize_lossy(project_root);
72 root.join(get_ito_dir_name_fs(fs, &root, ctx))
73}
74
75pub fn absolutize_and_normalize(input: &Path) -> std::io::Result<PathBuf> {
97 let abs = if input.is_absolute() {
98 input.to_path_buf()
99 } else {
100 std::env::current_dir()?.join(input)
101 };
102
103 Ok(lexical_normalize(&abs))
104}
105
106fn absolutize_and_normalize_lossy(input: &Path) -> PathBuf {
119 absolutize_and_normalize(input).unwrap_or_else(|_| lexical_normalize(input))
120}
121
122pub fn lexical_normalize(path: &Path) -> PathBuf {
143 use std::path::Component;
144
145 let mut out = PathBuf::new();
146 let mut stack: Vec<std::ffi::OsString> = Vec::new();
147 let mut rooted = false;
148
149 for c in path.components() {
150 match c {
151 Component::Prefix(p) => {
152 out.push(p.as_os_str());
153 }
154 Component::RootDir => {
155 rooted = true;
156 }
157 Component::CurDir => {}
158 Component::ParentDir => {
159 if let Some(last) = stack.last()
160 && last != ".."
161 {
162 stack.pop();
163 continue;
164 }
165 if !rooted {
166 stack.push(std::ffi::OsString::from(".."));
167 }
168 }
169 Component::Normal(seg) => {
170 stack.push(seg.to_os_string());
171 }
172 }
173 }
174
175 if rooted {
176 out.push(std::path::MAIN_SEPARATOR.to_string());
177 }
178 for seg in stack {
179 out.push(seg);
180 }
181
182 out
183}
184
185fn sanitize_ito_dir_name(input: &str) -> Option<String> {
186 let input = input.trim();
187 if input.is_empty() {
188 return None;
189 }
190
191 if input.len() > 128 {
192 return None;
193 }
194
195 if input.contains('/') || input.contains('\\') || input.contains("..") {
196 return None;
197 }
198
199 if Path::new(input).is_absolute() {
200 return None;
201 }
202
203 Some(input.to_string())
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn get_ito_dir_name_defaults_to_dot_ito() {
212 let td = tempfile::tempdir().unwrap();
213 let ctx = ConfigContext::default();
214 assert_eq!(get_ito_dir_name(td.path(), &ctx), ".ito");
215 }
216
217 #[test]
218 fn repo_config_overrides_global_config() {
219 let td = tempfile::tempdir().unwrap();
220 std::fs::write(
221 td.path().join("ito.json"),
222 "{\"projectPath\":\".repo-ito\"}",
223 )
224 .unwrap();
225
226 let home = tempfile::tempdir().unwrap();
227 let cfg_dir = home.path().join(".config/ito");
228 std::fs::create_dir_all(&cfg_dir).unwrap();
229 std::fs::write(
230 cfg_dir.join("config.json"),
231 "{\"projectPath\":\".global-ito\"}",
232 )
233 .unwrap();
234
235 let ctx = ConfigContext {
236 xdg_config_home: None,
237 home_dir: Some(home.path().to_path_buf()),
238 project_dir: None,
239 };
240
241 assert_eq!(get_ito_dir_name(td.path(), &ctx), ".repo-ito");
242 }
243
244 #[test]
245 fn dot_repo_config_overrides_repo_config() {
246 let td = tempfile::tempdir().unwrap();
247 std::fs::write(
248 td.path().join("ito.json"),
249 "{\"projectPath\":\".repo-ito\"}",
250 )
251 .unwrap();
252 std::fs::write(
253 td.path().join(".ito.json"),
254 "{\"projectPath\":\".dot-ito\"}",
255 )
256 .unwrap();
257
258 let ctx = ConfigContext::default();
259 assert_eq!(get_ito_dir_name(td.path(), &ctx), ".dot-ito");
260 }
261
262 #[test]
263 fn get_ito_path_normalizes_dotdot_segments() {
264 let td = tempfile::tempdir().unwrap();
265 let repo = td.path();
266 std::fs::create_dir_all(repo.join("a")).unwrap();
267 std::fs::create_dir_all(repo.join("b")).unwrap();
268
269 let ctx = ConfigContext::default();
270 let p = repo.join("a/../b");
271
272 let ito_path = get_ito_path(&p, &ctx);
273 assert!(ito_path.ends_with("b/.ito"));
274 }
275
276 #[test]
277 fn invalid_repo_project_path_falls_back_to_default() {
278 let td = tempfile::tempdir().unwrap();
279 std::fs::write(
280 td.path().join("ito.json"),
281 "{\"projectPath\":\"../escape\"}",
282 )
283 .unwrap();
284
285 let ctx = ConfigContext::default();
286 assert_eq!(get_ito_dir_name(td.path(), &ctx), ".ito");
287 }
288
289 #[test]
290 fn sanitize_rejects_path_separators_and_overlong_values() {
291 assert_eq!(sanitize_ito_dir_name(".ito"), Some(".ito".to_string()));
292 assert_eq!(sanitize_ito_dir_name("../x"), None);
293 assert_eq!(sanitize_ito_dir_name("a/b"), None);
294 assert_eq!(sanitize_ito_dir_name("a\\b"), None);
295 assert_eq!(sanitize_ito_dir_name(&"a".repeat(129)), None);
296 }
297}