Skip to content

Commit 00ee92e

Browse files
committed
fix(processor): refine error handling for file/directory path conflicts
Addresses failing tests observed specifically on the Ubuntu CI runner. These failures were related to filesystem operations where path components might exist as files instead of the expected directories. The previous error handling in the processor could mask these specific file/directory conflict errors, often reporting them as generic I/O or path canonicalization failures. This inconsistency likely caused test assertions (e.g., on summary counts or specific error types) to fail on the Ubuntu environment. This change improves the robustness and cross-platform consistency of error handling by: - Using `fs::metadata` more proactively in `create.rs` and `safety.rs` to check the type of existing path components before proceeding. - Explicitly catching `io::ErrorKind::NotADirectory` during metadata checks, directory creation (`create_dir_all`), file writing (`fs::write`), and path canonicalization. - Mapping these specific file/directory conflict errors to the more precise `ProcessError::ParentIsNotDirectory` and `ProcessError::TargetIsDirectory` variants. - Ensuring the `summary_updater` correctly increments specific failure counters (`failed_parent_isdir`, `failed_isdir_create`) based on these refined error types, leading to more accurate summaries. - Centralizing summary updates for path format errors in `action_handler.rs`. This resolves the CI failures by providing more accurate error detection and reporting, leading to consistent behavior and correct summary counts across different environments.
1 parent 4169478 commit 00ee92e

File tree

3 files changed

+164
-48
lines changed

3 files changed

+164
-48
lines changed

src/processor/action_handler.rs

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,54 +27,70 @@ pub(crate) fn process_single_action(
2727
);
2828

2929
// --- Validate Path Format (String-based check FIRST) ---
30+
// (Validation logic remains the same)
3031
if relative_path_str.contains("//") || relative_path_str.contains(r"\\") {
3132
eprintln!(
3233
"Error: Invalid path format (consecutive separators) for '{}'. Skipping.",
3334
relative_path_str
3435
);
35-
summary.failed_unsafe += 1;
36+
// Directly update summary for format errors before path conversion
37+
summary_updater::update_summary_error(
38+
summary,
39+
ProcessError::InvalidPathFormat {
40+
path: relative_path_str.to_string(),
41+
},
42+
);
3643
return;
3744
}
38-
// Also check for trailing separators (unless it's just root)
3945
if (relative_path_str.ends_with('/') || relative_path_str.ends_with('\\'))
4046
&& relative_path_str.len() > 1
4147
{
4248
eprintln!(
4349
"Error: Invalid path format (trailing separator) for '{}'. Skipping.",
4450
relative_path_str
4551
);
46-
summary.failed_unsafe += 1;
52+
summary_updater::update_summary_error(
53+
summary,
54+
ProcessError::InvalidPathFormat {
55+
path: relative_path_str.to_string(),
56+
},
57+
);
4758
return;
4859
}
49-
// Check for completely empty path string
5060
if relative_path_str.trim().is_empty() {
5161
eprintln!("Error: Invalid path format (empty path string). Skipping.");
52-
summary.failed_unsafe += 1;
62+
summary_updater::update_summary_error(
63+
summary,
64+
ProcessError::InvalidPathFormat {
65+
path: relative_path_str.to_string(),
66+
},
67+
);
5368
return;
5469
}
5570

5671
// --- Convert to PathBuf and check components (redundant but safe) ---
5772
let relative_path = PathBuf::from(relative_path_str);
58-
// This check might be redundant now but doesn't hurt
5973
if relative_path
6074
.components()
6175
.any(|comp| comp.as_os_str().is_empty())
6276
{
6377
eprintln!("Error: Invalid path format (empty components detected after PathBuf conversion) for '{}'. Skipping.", relative_path_str);
64-
summary.failed_unsafe += 1;
78+
summary_updater::update_summary_error(
79+
summary,
80+
ProcessError::InvalidPathFormat {
81+
path: relative_path_str.to_string(),
82+
},
83+
);
6584
return;
6685
}
6786
let potential_full_path = resolved_base.join(&relative_path);
6887

6988
// --- Safety Check ---
7089
if let Err(e) = safety::ensure_path_safe(resolved_base, &potential_full_path) {
7190
eprintln!("Error processing action for '{}': {}", relative_path_str, e);
72-
match e {
73-
ProcessError::PathNotSafe { .. } => summary.failed_unsafe += 1,
74-
ProcessError::PathResolution { .. } | ProcessError::Io { .. } => summary.failed_io += 1,
75-
_ => summary.error_other += 1, // Should not happen from safety check
76-
}
77-
return;
91+
// *** Call the summary updater function here ***
92+
summary_updater::update_summary_error(summary, e);
93+
return; // Exit before dispatching
7894
}
7995

8096
// --- Dispatch to Action Handler ---
@@ -93,7 +109,10 @@ pub(crate) fn process_single_action(
93109

94110
// --- Handle Errors from Action Handlers ---
95111
if let Err(e) = result {
112+
// Debug log can be removed now if desired
113+
// eprintln!("[DEBUG] Action handler returned error variant: {:?}", e);
96114
eprintln!("Error processing action for '{}': {}", relative_path_str, e);
115+
// *** Call the summary updater function here too ***
97116
summary_updater::update_summary_error(summary, e);
98117
}
99118
}

src/processor/create.rs

Lines changed: 73 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use crate::core_types::{Action, CreateStatus};
44
use crate::errors::ProcessError;
55
use std::fs;
6+
use std::io; // Import io for ErrorKind
67
use std::path::Path;
78

89
/// Creates or overwrites a file with the provided content.
@@ -19,6 +20,7 @@ pub(crate) fn process_create(
1920
.ok_or_else(|| ProcessError::Internal("Missing content for create action".to_string()))?;
2021

2122
// Ensure parent directory exists and is a directory
23+
// This might return ParentIsNotDirectory if parent exists as file or if creation fails
2224
ensure_parent_directory(resolved_full_path, resolved_base)?;
2325

2426
let mut status = CreateStatus::Created; // Default optimistic status
@@ -53,8 +55,26 @@ pub(crate) fn process_create(
5355
}
5456

5557
// Write the file content (as bytes to preserve line endings)
56-
fs::write(resolved_full_path, content.as_bytes())
57-
.map_err(|e| ProcessError::Io { source: e })?;
58+
fs::write(resolved_full_path, content.as_bytes()).map_err(|e| {
59+
// Check if the write failed because the parent path component is a file
60+
if e.kind() == io::ErrorKind::NotADirectory {
61+
// Map this specific IO error to our more descriptive error
62+
let parent_path = resolved_full_path
63+
.parent()
64+
.unwrap_or(resolved_full_path)
65+
.to_path_buf();
66+
// *** DEBUG LOG ***
67+
// eprintln!("[DEBUG] fs::write failed with NotADirectory, mapping to ParentIsNotDirectory for path: {}", resolved_full_path.display());
68+
ProcessError::ParentIsNotDirectory {
69+
path: resolved_full_path.to_path_buf(),
70+
parent_path, // Report the parent path
71+
}
72+
} else {
73+
// *** DEBUG LOG ***
74+
// eprintln!("[DEBUG] fs::write failed with other IO error: {:?}, mapping to Io for path: {}", e.kind(), resolved_full_path.display());
75+
ProcessError::Io { source: e }
76+
}
77+
})?;
5878

5979
Ok(status)
6080
}
@@ -63,21 +83,59 @@ pub(crate) fn process_create(
6383
/// Also checks if the parent path itself is unexpectedly a file.
6484
fn ensure_parent_directory(target_path: &Path, resolved_base: &Path) -> Result<(), ProcessError> {
6585
if let Some(parent_dir) = target_path.parent() {
66-
if parent_dir == resolved_base || parent_dir.exists() {
67-
// If parent exists, ensure it's a directory
68-
if !parent_dir.is_dir() {
69-
return Err(ProcessError::ParentIsNotDirectory {
70-
path: target_path.to_path_buf(),
71-
parent_path: parent_dir.to_path_buf(),
72-
});
86+
// Avoid checking the base directory itself if it's the parent
87+
if parent_dir == resolved_base || parent_dir.as_os_str().is_empty() {
88+
return Ok(()); // Base directory is guaranteed to exist and be a dir, or path is in root
89+
}
90+
91+
match fs::metadata(parent_dir) {
92+
Ok(metadata) => {
93+
// Parent exists, check if it's a directory
94+
if !metadata.is_dir() {
95+
// *** DEBUG LOG ***
96+
// eprintln!("[DEBUG] Parent metadata exists but is not dir, returning ParentIsNotDirectory for parent: {}", parent_dir.display());
97+
return Err(ProcessError::ParentIsNotDirectory {
98+
path: target_path.to_path_buf(),
99+
parent_path: parent_dir.to_path_buf(),
100+
});
101+
}
102+
// Parent exists and is a directory, all good.
103+
}
104+
Err(ref e) if e.kind() == io::ErrorKind::NotFound => {
105+
// Parent does not exist, try to create it
106+
let relative_parent_dir =
107+
parent_dir.strip_prefix(resolved_base).unwrap_or(parent_dir);
108+
println!(" Creating directory: {}", relative_parent_dir.display());
109+
110+
if let Err(create_err) = fs::create_dir_all(parent_dir) {
111+
// Check if the error is specifically "Not a directory"
112+
// This often indicates an intermediate path component was a file during creation attempt.
113+
if create_err.kind() == io::ErrorKind::NotADirectory {
114+
// *** DEBUG LOG ***
115+
// eprintln!("[DEBUG] create_dir_all failed with NotADirectory, returning ParentIsNotDirectory for parent: {}", parent_dir.display());
116+
// Map this specific IO error to our more descriptive error
117+
return Err(ProcessError::ParentIsNotDirectory {
118+
path: target_path.to_path_buf(),
119+
// Report the parent directory we *failed* to create
120+
parent_path: parent_dir.to_path_buf(),
121+
});
122+
} else {
123+
// *** DEBUG LOG ***
124+
// eprintln!("[DEBUG] create_dir_all failed with other IO error: {:?}, returning Io for parent: {}", create_err.kind(), parent_dir.display());
125+
// Other I/O error during creation
126+
return Err(ProcessError::Io { source: create_err });
127+
}
128+
}
129+
// Creation successful
130+
}
131+
Err(e) => {
132+
// *** DEBUG LOG ***
133+
// eprintln!("[DEBUG] fs::metadata failed with other IO error: {:?}, returning Io for parent: {}", e.kind(), parent_dir.display());
134+
// Other error getting metadata (permissions?)
135+
return Err(ProcessError::Io { source: e });
73136
}
74-
} else {
75-
// Parent does not exist, create it
76-
let relative_parent_dir = parent_dir.strip_prefix(resolved_base).unwrap_or(parent_dir);
77-
println!(" Creating directory: {}", relative_parent_dir.display());
78-
fs::create_dir_all(parent_dir).map_err(|e| ProcessError::Io { source: e })?;
79137
}
80138
}
81-
// If no parent (e.g., root directory), assume it's okay.
139+
// If no parent (e.g., file directly in base), assume it's okay.
82140
Ok(())
83141
}

src/processor/safety.rs

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,73 @@
11
//! Path safety validation logic.
22
33
use crate::errors::ProcessError;
4-
use std::io::ErrorKind;
4+
use std::fs; // Use fs::metadata
5+
use std::io::ErrorKind; // Import io and ErrorKind
56
use std::path::Path;
67

78
/// Checks if the target path is safely within the base directory.
8-
/// Canonicalizes both paths for reliable comparison.
9+
/// Canonicalizes paths for reliable comparison.
910
pub(crate) fn ensure_path_safe(base_dir: &Path, target_path: &Path) -> Result<(), ProcessError> {
1011
// Canonicalize base directory (must succeed as it's resolved in process_actions)
11-
let canonical_base = base_dir
12-
.canonicalize()
13-
.map_err(|e| ProcessError::Io { source: e })?;
12+
let canonical_base = match base_dir.canonicalize() {
13+
Ok(path) => path,
14+
// Handle potential NotADirectory error if base_dir itself is somehow a file (should be caught earlier, but belt-and-suspenders)
15+
Err(e) if e.kind() == ErrorKind::NotADirectory => {
16+
return Err(ProcessError::ParentIsNotDirectory {
17+
// Technically the base itself isn't a dir
18+
path: base_dir.to_path_buf(),
19+
parent_path: base_dir.parent().unwrap_or(base_dir).to_path_buf(),
20+
});
21+
}
22+
Err(e) => return Err(ProcessError::Io { source: e }),
23+
};
1424

15-
// Attempt to canonicalize the target path.
16-
match target_path.canonicalize() {
17-
Ok(canonical_target) => {
18-
// If target exists and canonicalizes, check if it starts with the base
19-
if canonical_target.starts_with(&canonical_base) {
20-
Ok(()) // Path is safe
21-
} else {
22-
Err(ProcessError::PathNotSafe {
23-
resolved_path: canonical_target,
24-
base_path: canonical_base,
25-
})
25+
// Check if the target path *exists* first using metadata.
26+
match fs::metadata(target_path) {
27+
Ok(_) => {
28+
// Target exists. Canonicalize it for the safety check.
29+
match target_path.canonicalize() {
30+
Ok(canonical_target) => {
31+
if canonical_target.starts_with(&canonical_base) {
32+
Ok(()) // Path exists and is safe
33+
} else {
34+
Err(ProcessError::PathNotSafe {
35+
resolved_path: canonical_target,
36+
base_path: canonical_base,
37+
})
38+
}
39+
}
40+
// *** CATCH NotADirectory during canonicalization of EXISTING target ***
41+
Err(e) if e.kind() == ErrorKind::NotADirectory => {
42+
Err(ProcessError::ParentIsNotDirectory {
43+
path: target_path.to_path_buf(),
44+
parent_path: target_path.parent().unwrap_or(target_path).to_path_buf(),
45+
})
46+
}
47+
Err(e) => {
48+
// Error canonicalizing an *existing* path (permissions?)
49+
Err(ProcessError::PathResolution {
50+
path: target_path.to_path_buf(),
51+
details: format!("Failed to canonicalize existing target path: {}", e),
52+
})
53+
}
2654
}
2755
}
2856
Err(ref e) if e.kind() == ErrorKind::NotFound => {
2957
// Target doesn't exist: Check safety based on its intended parent.
3058
check_nonexistent_target_safety(target_path, &canonical_base)
3159
}
32-
Err(e) => {
33-
// Other error during canonicalization (e.g., permission denied)
34-
Err(ProcessError::PathResolution {
60+
// *** CATCH NotADirectory during metadata check (parent is file) ***
61+
Err(ref e) if e.kind() == ErrorKind::NotADirectory => {
62+
Err(ProcessError::ParentIsNotDirectory {
3563
path: target_path.to_path_buf(),
36-
details: format!("Failed to canonicalize target path: {}", e),
64+
parent_path: target_path.parent().unwrap_or(target_path).to_path_buf(),
3765
})
3866
}
67+
Err(e) => {
68+
// Other error getting metadata (permissions?)
69+
Err(ProcessError::Io { source: e }) // Map other metadata errors to IO
70+
}
3971
}
4072
}
4173

@@ -60,6 +92,13 @@ fn check_nonexistent_target_safety(
6092
})
6193
}
6294
}
95+
// *** CATCH NotADirectory during PARENT canonicalization ***
96+
Err(ref parent_err) if parent_err.kind() == ErrorKind::NotADirectory => {
97+
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
100+
})
101+
}
63102
Err(ref parent_err) if parent_err.kind() == ErrorKind::NotFound => {
64103
// Parent directory itself doesn't exist. This implies it needs to be
65104
// created. Assume `create_dir_all` will handle safety within the base.

0 commit comments

Comments
 (0)