1use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use crate::error::CliCoreError;
14
15fn env_path(key: &str) -> Option<PathBuf> {
17 std::env::var(key)
18 .ok()
19 .filter(|v| !v.is_empty())
20 .map(PathBuf::from)
21}
22
23fn home_config_dir() -> Option<PathBuf> {
25 env_path("HOME").map(|home| home.join(".config"))
26}
27
28#[must_use]
34pub fn config_base_dir() -> Option<PathBuf> {
35 env_path("XDG_CONFIG_HOME")
36 .or_else(|| {
37 if cfg!(windows) {
44 env_path("APPDATA").or_else(home_config_dir)
45 } else {
46 home_config_dir().or_else(|| env_path("APPDATA"))
47 }
48 })
49 .filter(|p| p.is_absolute())
52}
53
54#[must_use]
69pub fn is_safe_path_component(s: &str) -> bool {
70 const FORBIDDEN: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
74 if s.contains(FORBIDDEN) || s.bytes().any(|b| b < 0x20 || b == 0x7F) {
75 return false;
76 }
77 if s.starts_with(' ') || s.ends_with('.') || s.ends_with(' ') {
78 return false;
79 }
80 const RESERVED: &[&str] = &[
83 "CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
84 "COM8", "COM9", "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
85 "LPT9",
86 ];
87 let stem = Path::new(s)
88 .file_stem()
89 .and_then(|s| s.to_str())
90 .unwrap_or(s);
91 if RESERVED.iter().any(|r| stem.eq_ignore_ascii_case(r)) {
92 return false;
93 }
94 let mut components = Path::new(s).components();
95 matches!(components.next(), Some(std::path::Component::Normal(_)))
96 && components.next().is_none()
97}
98
99pub fn write_string_atomic(path: &Path, contents: &str) -> crate::Result<()> {
112 if let Some(parent) = path.parent() {
113 std::fs::create_dir_all(parent)
114 .map_err(|e| CliCoreError::message(format!("failed to create directory: {e}")))?;
115 #[cfg(unix)]
116 {
117 use std::os::unix::fs::PermissionsExt as _;
118 if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
119 {
120 tracing::debug!(
121 path = %parent.display(),
122 error = %e,
123 "could not restrict directory permissions"
124 );
125 }
126 }
127 }
128 static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
131 let unique = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
132 let pid = std::process::id();
133 let tmp_path = path.with_file_name(format!(
134 "{}.{pid:x}.{unique:x}.tmp",
135 path.file_name().and_then(|s| s.to_str()).unwrap_or("tmp"),
136 ));
137 write_tmp_file(&tmp_path, contents)?;
138 if let Err(e) = std::fs::rename(&tmp_path, path) {
139 std::fs::remove_file(&tmp_path).ok();
140 return Err(CliCoreError::message(format!(
141 "failed to finalize {}: {e}",
142 path.display()
143 )));
144 }
145 Ok(())
146}
147
148fn write_tmp_file(tmp_path: &Path, contents: &str) -> crate::Result<()> {
151 use std::io::Write as _;
152 let mut opts = std::fs::OpenOptions::new();
153 opts.write(true).create_new(true);
154 #[cfg(unix)]
155 {
156 use std::os::unix::fs::OpenOptionsExt as _;
157 opts.mode(0o600);
158 }
159 let mut file = opts.open(tmp_path).map_err(|e| {
160 CliCoreError::message(format!("failed to write {}: {e}", tmp_path.display()))
161 })?;
162 file.write_all(contents.as_bytes())
163 .map_err(|e| CliCoreError::message(format!("failed to write {}: {e}", tmp_path.display())))
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::config::test_env::with_xdg_config_home;
170
171 #[test]
172 fn safe_path_component_basic() {
173 assert!(is_safe_path_component("godaddy"));
174 assert!(!is_safe_path_component(".."));
175 assert!(!is_safe_path_component(""));
176 assert!(!is_safe_path_component("a/b"));
177 assert!(!is_safe_path_component("NUL"));
178 }
179
180 #[test]
181 fn safe_path_component_rejects_windows_reserved_names() {
182 for name in &[
183 "CON", "con", "NUL", "nul", "COM1", "LPT9", "CON.txt", "NUL.json",
184 ] {
185 assert!(
186 !is_safe_path_component(name),
187 "{name:?} should be rejected as a Windows reserved name"
188 );
189 }
190 }
191
192 #[test]
193 fn safe_path_component_rejects_control_and_space_edges() {
194 assert!(!is_safe_path_component(" prod"), "leading space");
195 assert!(!is_safe_path_component("prod\x7f"), "DEL byte");
196 assert!(!is_safe_path_component("prod."), "trailing dot");
197 assert!(!is_safe_path_component("prod "), "trailing space");
198 }
199
200 #[test]
201 fn safe_path_component_accepts_normal_values() {
202 for name in &["dev", "prod", "staging", "my-app", "my_app", "app.v2"] {
203 assert!(is_safe_path_component(name), "{name:?} should be accepted");
204 }
205 }
206
207 #[test]
208 fn config_base_dir_rejects_relative_xdg() {
209 with_xdg_config_home(Path::new("."), || {
210 assert!(
211 config_base_dir().is_none(),
212 "relative XDG_CONFIG_HOME should be rejected"
213 );
214 });
215 }
216
217 #[test]
218 fn config_base_dir_honors_xdg() {
219 let dir = std::env::temp_dir().join("cli-engine-fs-base-test");
220 with_xdg_config_home(&dir, || {
221 assert_eq!(config_base_dir(), Some(dir.clone()));
222 });
223 }
224
225 #[tokio::test]
226 async fn write_string_atomic_round_trip_creates_dirs() {
227 let tmp = tempfile::tempdir().expect("tempdir");
228 let path = tmp.path().join("nested").join("file.txt");
229 write_string_atomic(&path, "hello").expect("write");
230 assert_eq!(std::fs::read_to_string(&path).expect("read"), "hello");
231 write_string_atomic(&path, "world").expect("rewrite");
233 assert_eq!(std::fs::read_to_string(&path).expect("read"), "world");
234 let strays: Vec<_> = std::fs::read_dir(path.parent().expect("parent"))
236 .expect("read_dir")
237 .filter_map(|e| e.ok())
238 .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
239 .collect();
240 assert!(strays.is_empty(), "temp files should be renamed away");
241 }
242
243 #[cfg(unix)]
244 #[tokio::test]
245 async fn write_string_atomic_sets_owner_only_mode() {
246 use std::os::unix::fs::PermissionsExt as _;
247 let tmp = tempfile::tempdir().expect("tempdir");
248 let path = tmp.path().join("secret.txt");
249 write_string_atomic(&path, "s3cr3t").expect("write");
250 let mode = std::fs::metadata(&path).expect("meta").permissions().mode() & 0o777;
251 assert_eq!(mode, 0o600, "file should be owner read/write only");
252 }
253}