koda_core/
bash_path_lint.rs1use path_clean::PathClean;
26use std::path::{Path, PathBuf};
27
28use crate::bash_safety::split_command_segments;
29use crate::bash_safety::strip_env_vars;
30use crate::bash_safety::strip_quoted_strings;
31
32pub fn is_safe_external_path(resolved: &Path) -> bool {
39 if resolved.starts_with("/dev/") {
41 return true;
42 }
43
44 let canonical_tmp = PathBuf::from("/tmp")
46 .canonicalize()
47 .unwrap_or_else(|_| PathBuf::from("/tmp"));
48 if resolved.starts_with(&canonical_tmp) || resolved.starts_with("/tmp") {
49 return true;
50 }
51
52 if let Ok(tmpdir) = std::env::var("TMPDIR") {
54 let tmpdir_path = PathBuf::from(&tmpdir);
55 let canonical_tmpdir = tmpdir_path
56 .canonicalize()
57 .unwrap_or_else(|_| tmpdir_path.clone());
58 if resolved.starts_with(&canonical_tmpdir) || resolved.starts_with(&tmpdir_path) {
59 return true;
60 }
61 }
62
63 false
64}
65
66#[derive(Debug, Clone, Default)]
68pub struct BashPathLint {
69 pub outside_paths: Vec<String>,
71 pub home_escape: bool,
73}
74
75impl BashPathLint {
76 pub fn has_warnings(&self) -> bool {
78 !self.outside_paths.is_empty() || self.home_escape
79 }
80}
81
82pub fn lint_bash_paths(command: &str, project_root: &Path) -> BashPathLint {
97 let mut lint = BashPathLint::default();
98 let trimmed = command.trim();
99 if trimmed.is_empty() {
100 return lint;
101 }
102
103 let segments = split_command_segments(trimmed);
104
105 for segment in &segments {
106 let seg = segment.trim();
107
108 if let Some(target) = extract_cd_target(seg) {
110 match target {
111 CdTarget::Home => lint.home_escape = true,
112 CdTarget::Dynamic => {} CdTarget::Path(p) => {
114 let path = Path::new(&p);
115 let resolved = if path.is_absolute() {
116 path.to_path_buf().clean()
117 } else {
118 project_root.join(&p).clean()
119 };
120 if !resolved.starts_with(project_root) && !is_safe_external_path(&resolved) {
121 lint.outside_paths.push(p);
122 }
123 }
124 }
125 }
126
127 let unquoted = strip_quoted_strings(seg);
131 for token in unquoted.split_whitespace().skip(1) {
132 if token.starts_with('-') {
133 continue;
134 }
135 if token.starts_with('/') {
136 let resolved = Path::new(token).to_path_buf().clean();
137 if !resolved.starts_with(project_root) && !is_safe_external_path(&resolved) {
138 lint.outside_paths.push(token.to_string());
139 }
140 }
141 if token.contains("..") {
142 let resolved = project_root.join(token).clean();
143 if !resolved.starts_with(project_root) && !is_safe_external_path(&resolved) {
144 lint.outside_paths.push(token.to_string());
145 }
146 }
147 }
148 }
149
150 lint.outside_paths.sort();
151 lint.outside_paths.dedup();
152 lint
153}
154
155#[derive(Debug)]
156enum CdTarget {
157 Home,
158 Dynamic,
159 Path(String),
160}
161
162fn extract_cd_target(segment: &str) -> Option<CdTarget> {
164 let seg = segment.trim();
165 let seg = strip_env_vars(seg);
166 let seg = seg.trim();
167
168 if seg == "cd" {
169 return Some(CdTarget::Home);
170 }
171 if !seg.starts_with("cd ") && !seg.starts_with("cd\t") {
172 return None;
173 }
174
175 let target = seg[2..].trim();
176
177 if target.is_empty() || target == "~" {
178 return Some(CdTarget::Home);
179 }
180 if target.starts_with('$') || target.starts_with('`') || target.contains("$(") {
181 return Some(CdTarget::Dynamic);
182 }
183
184 Some(CdTarget::Path(
185 target
186 .split_whitespace()
187 .next()
188 .unwrap_or(target)
189 .to_string(),
190 ))
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 fn project() -> std::path::PathBuf {
198 std::path::PathBuf::from("/home/user/project")
199 }
200
201 #[test]
202 fn test_lint_safe_command() {
203 let lint = lint_bash_paths("cargo test", &project());
204 assert!(!lint.has_warnings());
205 }
206
207 #[test]
208 fn test_lint_cd_inside_project() {
209 let lint = lint_bash_paths("cd src && ls", &project());
210 assert!(!lint.has_warnings());
211 }
212
213 #[test]
214 fn test_lint_cd_outside_project() {
215 let lint = lint_bash_paths("cd /etc && ls", &project());
216 assert!(lint.has_warnings());
217 assert!(lint.outside_paths.contains(&"/etc".to_string()));
218 }
219
220 #[test]
221 fn test_lint_cd_home() {
222 let lint = lint_bash_paths("cd ~", &project());
223 assert!(lint.home_escape);
224 }
225
226 #[test]
227 fn test_lint_bare_cd() {
228 let lint = lint_bash_paths("cd", &project());
229 assert!(lint.home_escape);
230 }
231
232 #[test]
233 fn test_lint_cd_dynamic_ignored() {
234 let lint = lint_bash_paths("cd $SOME_DIR", &project());
235 assert!(!lint.has_warnings());
236 }
237
238 #[test]
239 fn test_lint_absolute_path_arg() {
240 let lint = lint_bash_paths("cp file.txt /etc/hosts", &project());
241 assert!(lint.has_warnings());
242 assert!(lint.outside_paths.contains(&"/etc/hosts".to_string()));
243 }
244
245 #[test]
246 fn test_lint_relative_escape() {
247 let lint = lint_bash_paths("cat ../../../etc/passwd", &project());
248 assert!(lint.has_warnings());
249 }
250
251 #[test]
252 fn test_lint_relative_inside() {
253 let lint = lint_bash_paths("cat ../project/src/main.rs", &project());
254 assert!(!lint.has_warnings());
255 }
256
257 #[test]
258 fn test_lint_path_inside_project_absolute() {
259 let lint = lint_bash_paths("ls /home/user/project/src", &project());
260 assert!(!lint.has_warnings());
261 }
262
263 #[test]
264 fn test_lint_empty_command() {
265 let lint = lint_bash_paths("", &project());
266 assert!(!lint.has_warnings());
267 }
268
269 #[test]
270 fn test_lint_deduplicates() {
271 let lint = lint_bash_paths("cp /etc/a /etc/b", &project());
272 assert!(lint.has_warnings());
273 assert_eq!(lint.outside_paths.len(), 2);
274 }
275
276 #[test]
279 fn test_lint_tmp_path_allowed() {
280 let lint = lint_bash_paths("cat /tmp/issue-draft.md", &project());
281 assert!(!lint.has_warnings());
282 }
283
284 #[test]
285 fn test_lint_cd_tmp_allowed() {
286 let lint = lint_bash_paths("cd /tmp && ls", &project());
287 assert!(!lint.has_warnings());
288 }
289
290 #[test]
291 fn test_lint_tmp_subdir_allowed() {
292 let lint = lint_bash_paths("cp file.txt /tmp/koda/output.md", &project());
293 assert!(!lint.has_warnings());
294 }
295
296 #[test]
297 fn test_lint_dev_null_allowed() {
298 let lint = lint_bash_paths("echo test > /dev/null", &project());
299 assert!(!lint.has_warnings());
300 }
301
302 #[test]
303 fn test_lint_etc_still_blocked() {
304 let lint = lint_bash_paths("cat /etc/passwd", &project());
305 assert!(lint.has_warnings());
306 }
307
308 #[test]
311 fn test_lint_path_in_commit_message_ignored() {
312 let lint = lint_bash_paths(
313 r#"git commit -m "allow /tmp and /dev/* and /etc/hosts""#,
314 &project(),
315 );
316 assert!(!lint.has_warnings());
317 }
318
319 #[test]
320 fn test_lint_path_in_single_quotes_ignored() {
321 let lint = lint_bash_paths("echo 'fixed /etc/hosts parsing'", &project());
322 assert!(!lint.has_warnings());
323 }
324
325 #[test]
326 fn test_lint_path_outside_quotes_still_flagged() {
327 let lint = lint_bash_paths(r#"cp /etc/hosts "destination.txt""#, &project());
328 assert!(lint.has_warnings());
329 assert!(lint.outside_paths.contains(&"/etc/hosts".to_string()));
330 }
331
332 #[test]
333 fn test_lint_merge_with_message() {
334 let lint = lint_bash_paths(
335 r#"git merge fix/branch -m "feat: allow /tmp, $TMPDIR, and /dev/* (#560)""#,
336 &project(),
337 );
338 assert!(!lint.has_warnings());
339 }
340
341 #[test]
342 fn test_strip_quoted_strings() {
343 assert_eq!(
344 strip_quoted_strings(r#"git commit -m "allow /tmp""#),
345 r#"git commit -m " ""#
346 );
347 assert_eq!(
348 strip_quoted_strings("echo 'path /etc/hosts'"),
349 "echo ' '"
350 );
351 assert_eq!(strip_quoted_strings("cp /etc/a /etc/b"), "cp /etc/a /etc/b");
353 }
354}