Skip to content

Commit 08e8915

Browse files
committed
fix(processor): improve safety checks for non-existent and invalid paths
Refactors the path safety checking logic in `src/processor/safety.rs` to more robustly handle non-existent target paths and intermediate path components that are files instead of directories. - Replaced the previous `check_nonexistent_target_safety` with a recursive `check_nonexistent_path_safety` function. - Explicitly checks for `io::ErrorKind::NotADirectory` during canonicalization and metadata lookups for both the target path and its ancestors. - Maps `NotADirectory` errors encountered during safety checks to the more specific `ProcessError::ParentIsNotDirectory` for clearer error reporting. This commit also ensures the processing summary is always printed in `src/main.rs`, even if no actions are parsed from the input markdown file. Previously, the summary was skipped in this case. Supporting changes include: - Added unit tests (`src/processor/safety_tests.rs`) for the refactored safety logic. - Added CLI integration tests (`tests/cli/empty_input.rs`) to verify the summary output behavior for empty/non-actionable inputs. - Moved safety integration tests to unit tests. - Added a processor integration test for handling empty path strings.
1 parent 00ee92e commit 08e8915

File tree

8 files changed

+337
-55
lines changed

8 files changed

+337
-55
lines changed

src/main.rs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,34 +25,37 @@ fn run() -> Result<Summary, AppError> {
2525
println!("\nParsing markdown for file actions...");
2626
let parsed_actions = parse_markdown(&markdown_content)?; // Use lib function
2727

28+
// Check if actions were found and print appropriate message
2829
if parsed_actions.is_empty() {
2930
// Basic check if content might have had actionable items
3031
if markdown_content.contains("```")
3132
|| markdown_content.contains("//")
3233
|| markdown_content.contains("**")
3334
|| markdown_content.contains("##")
3435
{
35-
eprintln!("\nWarning: No valid actions extracted. Check formatting.");
36+
// CHANGE: Use println! instead of eprintln! for this warning
37+
println!("\nWarning: No valid actions extracted. Check formatting.");
3638
} else {
3739
println!("\nInfo: No actionable content found.");
3840
}
3941
println!("\nNo actions to process.");
40-
return Ok(Summary::default()); // Return empty summary
42+
// REMOVE the early return: continue processing to print the zero summary
43+
// return Ok(Summary::default()); // Return empty summary
44+
} else {
45+
println!(
46+
"\nFound {} actions to process (sorted by document order).",
47+
parsed_actions.len()
48+
);
4149
}
4250

43-
println!(
44-
"\nFound {} actions to process (sorted by document order).",
45-
parsed_actions.len()
46-
);
47-
48-
// Process actions using the library function
51+
// Process actions using the library function (will do nothing if actions is empty)
4952
let summary = process_actions(&cli.output_dir, parsed_actions, cli.force)?;
5053

5154
// Print summary needs the *resolved* base path for display
5255
// Resolve again for printing; process_actions resolves internally for safety.
5356
// Use original path if canonicalize fails (e.g., dir deleted during processing).
5457
let resolved_output_dir_display = cli.output_dir.canonicalize().unwrap_or(cli.output_dir);
55-
// Call the imported print_summary function
58+
// Call the imported print_summary function - THIS WILL NOW ALWAYS RUN
5659
print_summary(&summary, &resolved_output_dir_display);
5760

5861
Ok(summary)

src/processor/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ mod delete;
1313
mod safety;
1414
mod summary_updater;
1515

16+
// Declare the unit test module for safety
17+
#[cfg(test)]
18+
mod safety_tests;
19+
#[cfg(test)] // Also declare the existing summary_updater_tests module here
20+
mod summary_updater_tests;
21+
1622
/// Processes a list of actions against the filesystem relative to a base directory.
1723
pub fn process_actions(
1824
base_dir: &Path,

src/processor/safety.rs

Lines changed: 80 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ pub(crate) fn ensure_path_safe(base_dir: &Path, target_path: &Path) -> Result<()
5555
}
5656
Err(ref e) if e.kind() == ErrorKind::NotFound => {
5757
// Target doesn't exist: Check safety based on its intended parent.
58-
check_nonexistent_target_safety(target_path, &canonical_base)
58+
// Pass the original target_path for error reporting context if needed.
59+
check_nonexistent_path_safety(target_path, &canonical_base)
5960
}
6061
// *** CATCH NotADirectory during metadata check (parent is file) ***
6162
Err(ref e) if e.kind() == ErrorKind::NotADirectory => {
@@ -71,68 +72,101 @@ pub(crate) fn ensure_path_safe(base_dir: &Path, target_path: &Path) -> Result<()
7172
}
7273
}
7374

74-
/// Checks safety for a target path that does not yet exist by examining its parent.
75-
fn check_nonexistent_target_safety(
76-
target_path: &Path,
75+
/// Recursively checks safety for a path that does not necessarily exist
76+
/// by examining its ancestors relative to the canonical base.
77+
fn check_nonexistent_path_safety(
78+
path_to_check: &Path,
7779
canonical_base: &Path,
7880
) -> Result<(), ProcessError> {
79-
if let Some(parent) = target_path.parent() {
80-
// Canonicalize the parent directory.
81+
// Base case: If the path_to_check *is* the base directory, it's safe by definition.
82+
// We need to compare canonical paths if possible.
83+
match path_to_check.canonicalize() {
84+
Ok(canonical_check) if canonical_check == *canonical_base => return Ok(()),
85+
Ok(_) => {} // Path exists and is not the base, continue to parent check
86+
Err(ref e) if e.kind() == ErrorKind::NotFound => {} // Path doesn't exist, continue
87+
Err(ref e) if e.kind() == ErrorKind::NotADirectory => {
88+
// An intermediate component is a file.
89+
return Err(ProcessError::ParentIsNotDirectory {
90+
path: path_to_check.to_path_buf(), // Report the path we were checking
91+
parent_path: path_to_check
92+
.parent()
93+
.unwrap_or(path_to_check)
94+
.to_path_buf(),
95+
});
96+
}
97+
Err(e) => {
98+
// Other canonicalization error (permissions?)
99+
return Err(ProcessError::PathResolution {
100+
path: path_to_check.to_path_buf(),
101+
details: format!("Failed to canonicalize path during safety check: {}", e),
102+
});
103+
}
104+
}
105+
106+
// Get the parent of the path we are currently checking.
107+
if let Some(parent) = path_to_check.parent() {
108+
// If the parent *is* the base directory, the path is safe (it's directly inside).
109+
// Compare canonical paths if possible for robustness.
81110
match parent.canonicalize() {
82111
Ok(canonical_parent) => {
83-
// Check if the existing parent is within the base directory.
84-
if canonical_parent.starts_with(canonical_base) {
85-
// Parent is safe, so creating the target inside it is considered safe.
86-
Ok(())
87-
} else {
88-
// Parent exists but is outside the base directory. Unsafe.
89-
Err(ProcessError::PathNotSafe {
90-
resolved_path: canonical_parent, // Report the parent path
112+
if canonical_parent == *canonical_base {
113+
return Ok(());
114+
}
115+
// Parent exists and is not the base. Check if it's *within* the base.
116+
if !canonical_parent.starts_with(canonical_base) {
117+
return Err(ProcessError::PathNotSafe {
118+
resolved_path: canonical_parent, // Report the unsafe parent
91119
base_path: canonical_base.to_path_buf(),
92-
})
120+
});
93121
}
122+
// Parent exists and is within the base. Now, ensure it's actually a directory.
123+
match fs::metadata(&canonical_parent) {
124+
Ok(meta) if meta.is_dir() => {
125+
// Parent exists, is safe, and is a directory. Path is safe.
126+
Ok(())
127+
}
128+
Ok(_) => {
129+
// Parent exists, is safe, but is NOT a directory.
130+
Err(ProcessError::ParentIsNotDirectory {
131+
path: path_to_check.to_path_buf(), // The path we were trying to create/check
132+
parent_path: parent.to_path_buf(), // The parent that is not a directory
133+
})
134+
}
135+
Err(e) => {
136+
// Error getting metadata for the existing canonical parent (permissions?)
137+
Err(ProcessError::Io { source: e })
138+
}
139+
}
140+
}
141+
Err(ref e) if e.kind() == ErrorKind::NotFound => {
142+
// Parent does not exist. Recursively check the parent's safety.
143+
check_nonexistent_path_safety(parent, canonical_base)
94144
}
95-
// *** CATCH NotADirectory during PARENT canonicalization ***
96-
Err(ref parent_err) if parent_err.kind() == ErrorKind::NotADirectory => {
145+
Err(ref e) if e.kind() == ErrorKind::NotADirectory => {
146+
// An intermediate component in the *parent's* path is a file.
97147
Err(ProcessError::ParentIsNotDirectory {
98-
path: target_path.to_path_buf(), // The target we were trying to create
99-
parent_path: parent.to_path_buf(), // The parent that is not a directory
148+
path: path_to_check.to_path_buf(), // The original path being checked
149+
parent_path: parent.to_path_buf(), // The parent path that failed
100150
})
101151
}
102-
Err(ref parent_err) if parent_err.kind() == ErrorKind::NotFound => {
103-
// Parent directory itself doesn't exist. This implies it needs to be
104-
// created. Assume `create_dir_all` will handle safety within the base.
105-
// We could recursively check the parent's parent, but relying on
106-
// the initial base check and `create_dir_all` is often sufficient.
107-
// If the non-existent parent's path *string* looks unsafe (e.g., "../.."),
108-
// it might be caught earlier, but canonicalization handles this better.
109-
// For simplicity here, assume okay if parent *would* be created inside base.
110-
// A stricter check could trace the path components upwards.
111-
// Let's check if the parent *path itself* starts with base logically.
112-
if parent.starts_with(canonical_base) {
113-
Ok(())
114-
} else {
115-
// If even the logical parent path isn't inside base, it's unsafe.
116-
// This check is less robust than canonicalization but handles simple cases.
117-
Err(ProcessError::PathNotSafe {
118-
resolved_path: parent.to_path_buf(), // Report logical parent
119-
base_path: canonical_base.to_path_buf(),
120-
})
121-
}
122-
}
123-
Err(parent_err) => {
124-
// Other error canonicalizing the parent (e.g., permissions).
152+
Err(e) => {
153+
// Other error canonicalizing the parent (permissions?)
125154
Err(ProcessError::PathResolution {
126155
path: parent.to_path_buf(),
127-
details: format!("Failed to canonicalize parent directory: {}", parent_err),
156+
details: format!("Failed to canonicalize parent directory: {}", e),
128157
})
129158
}
130159
}
131160
} else {
132-
// Cannot get parent (e.g., root path "/" or similar). Unlikely for relative paths.
161+
// Cannot get parent (e.g., root path "/" or similar).
162+
// If we reached here, it means the path wasn't the base itself.
163+
// This implies an attempt to access something outside the base, potentially the root.
133164
Err(ProcessError::PathResolution {
134-
path: target_path.to_path_buf(),
135-
details: "Cannot determine parent directory for safety check.".to_string(),
165+
path: path_to_check.to_path_buf(),
166+
details: "Cannot determine parent directory for safety check (potentially root path)."
167+
.to_string(),
136168
})
137169
}
138170
}
171+
172+
// --- REMOVED old check_nonexistent_target_safety function ---

src/processor/safety_tests.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
//! Unit tests specifically for the path safety logic in src/processor/safety.rs
2+
//! These tests live alongside the code they test.
3+
4+
// Use assert_fs for temporary directory and file manipulation in tests
5+
use assert_fs::prelude::*;
6+
use assert_fs::TempDir;
7+
// Use crate::errors for ProcessError within the same crate
8+
use crate::errors::ProcessError;
9+
10+
// Helper function to call the safety check directly using super::
11+
// It now takes TempDir directly for convenience in setting up paths.
12+
fn check_safety(temp_dir: &TempDir, target_rel_path: &str) -> Result<(), ProcessError> {
13+
let base_path = temp_dir.path();
14+
let target_path = base_path.join(target_rel_path);
15+
// Need to canonicalize the base path first for the check function
16+
let canonical_base = base_path
17+
.canonicalize()
18+
.expect("Failed to canonicalize base path for test setup");
19+
// Call the function in the parent module's safety submodule
20+
super::safety::ensure_path_safe(&canonical_base, &target_path)
21+
}
22+
23+
// Helper to create a temp dir - replaces setup_temp_dir from test_common
24+
fn setup_temp_dir() -> TempDir {
25+
TempDir::new().expect("Failed to create temporary directory for test")
26+
}
27+
28+
#[test]
29+
fn test_safety_target_exists_safe() {
30+
let temp_dir = setup_temp_dir();
31+
temp_dir.child("safe_file.txt").write_str("safe").unwrap();
32+
assert!(check_safety(&temp_dir, "safe_file.txt").is_ok());
33+
}
34+
35+
#[test]
36+
fn test_safety_target_exists_unsafe() {
37+
let temp_dir = setup_temp_dir();
38+
// We can't easily create a file outside the temp dir in a portable way.
39+
// Instead, we test the logic using relative paths that *would* escape.
40+
// The function canonicalizes, so `base/../sibling` becomes `/path/to/sibling`.
41+
let result = check_safety(&temp_dir, "../unsafe_file.txt");
42+
assert!(matches!(result, Err(ProcessError::PathNotSafe { .. })));
43+
}
44+
45+
#[test]
46+
fn test_safety_target_not_exist_parent_safe() {
47+
let temp_dir = setup_temp_dir();
48+
temp_dir.child("safe_dir").create_dir_all().unwrap();
49+
// Target doesn't exist, but parent does and is inside base.
50+
assert!(check_safety(&temp_dir, "safe_dir/new_file.txt").is_ok());
51+
}
52+
53+
#[test]
54+
fn test_safety_target_not_exist_parent_unsafe() {
55+
let temp_dir = setup_temp_dir();
56+
// Parent resolves outside the base directory.
57+
let result = check_safety(&temp_dir, "../unsafe_dir/new_file.txt");
58+
assert!(matches!(result, Err(ProcessError::PathNotSafe { .. })));
59+
}
60+
61+
#[test]
62+
fn test_safety_target_not_exist_parent_not_exist_safe() {
63+
let temp_dir = setup_temp_dir();
64+
// Neither target nor parent exists, but the logical path is within base.
65+
assert!(check_safety(&temp_dir, "new_dir/new_file.txt").is_ok());
66+
}
67+
68+
#[test]
69+
fn test_safety_target_not_exist_parent_not_exist_unsafe() {
70+
let temp_dir = setup_temp_dir();
71+
// Neither target nor parent exists, and the logical path points outside.
72+
let result = check_safety(&temp_dir, "../new_unsafe_dir/new_file.txt");
73+
// This case relies on the non-canonicalized check in check_nonexistent_target_safety
74+
// or potentially fails during parent canonicalization if the unsafe parent *did* exist.
75+
// Given the current implementation, it should detect the parent is outside base.
76+
assert!(matches!(result, Err(ProcessError::PathNotSafe { .. })));
77+
}
78+
79+
#[test]
80+
fn test_safety_intermediate_component_is_file() {
81+
let temp_dir = setup_temp_dir();
82+
temp_dir
83+
.child("file_as_dir")
84+
.write_str("I am a file")
85+
.unwrap();
86+
// Try to create something inside the path where 'file_as_dir' is treated as a directory.
87+
let result = check_safety(&temp_dir, "file_as_dir/nested_file.txt");
88+
89+
// This should fail because the parent ('file_as_dir') is not a directory.
90+
// The error can come from metadata check or canonicalization depending on exact flow.
91+
assert!(
92+
matches!(result, Err(ProcessError::ParentIsNotDirectory { .. }))
93+
|| matches!(result, Err(ProcessError::Io { .. })) // Canonicalize might return generic IO error sometimes
94+
|| matches!(result, Err(ProcessError::PathResolution { .. })), // Or path resolution error
95+
"Expected ParentIsNotDirectory or related error, got {:?}",
96+
result
97+
);
98+
}
99+
100+
#[test]
101+
#[cfg(unix)] // Test behavior with symlinks (more relevant on Unix)
102+
fn test_safety_symlink_inside_base() {
103+
let temp_dir = setup_temp_dir();
104+
temp_dir.child("target_dir").create_dir_all().unwrap();
105+
temp_dir
106+
.child("target_dir/actual_file.txt")
107+
.write_str("actual")
108+
.unwrap();
109+
let link_path = temp_dir.path().join("safe_link");
110+
std::os::unix::fs::symlink("target_dir/actual_file.txt", &link_path)
111+
.expect("Failed to create symlink");
112+
113+
// Checking the link itself should be safe as it resides within base
114+
assert!(check_safety(&temp_dir, "safe_link").is_ok());
115+
// Checking a path *through* the link should also be safe if target is safe
116+
assert!(check_safety(&temp_dir, "safe_link").is_ok()); // ensure_path_safe canonicalizes
117+
}
118+
119+
#[test]
120+
#[cfg(unix)]
121+
fn test_safety_symlink_points_outside_base() {
122+
let temp_dir = setup_temp_dir();
123+
let sibling_dir = temp_dir
124+
.path()
125+
.parent()
126+
.unwrap()
127+
.join("strux_test_sibling_target");
128+
// Use std::fs or fs_err for consistency within unit tests if needed elsewhere
129+
std::fs::create_dir_all(&sibling_dir).expect("Failed to create sibling dir");
130+
std::fs::write(sibling_dir.join("outside_file.txt"), "outside")
131+
.expect("Failed to write outside file");
132+
133+
let link_path = temp_dir.path().join("unsafe_link");
134+
// Create a link pointing outside the temp dir
135+
std::os::unix::fs::symlink("../strux_test_sibling_target/outside_file.txt", &link_path)
136+
.expect("Failed to create symlink");
137+
138+
// Accessing the link itself is okay (it lives inside base)
139+
// But ensure_path_safe canonicalizes, revealing the unsafe target.
140+
let result = check_safety(&temp_dir, "unsafe_link");
141+
assert!(matches!(result, Err(ProcessError::PathNotSafe { .. })));
142+
143+
// Clean up the sibling directory
144+
std::fs::remove_dir_all(&sibling_dir).expect("Failed to remove sibling dir");
145+
}

tests/cli.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ mod basic;
1111
mod create;
1212
#[path = "cli/delete.rs"]
1313
mod delete;
14+
#[path = "cli/empty_input.rs"]
15+
mod empty_input;
1416
#[path = "cli/errors.rs"]
1517
mod errors;
1618
#[path = "cli/overwrite_skip.rs"]

0 commit comments

Comments
 (0)