Audit fixes: panic safety, path hardening, code quality

- Replace 2 production unwrap() calls in workspace.rs with proper error
  handling to prevent panics on inconsistent state
- Add AppState::save_config() helper to eliminate 11 duplicated
  save_to_file patterns in Tauri lib.rs
- Log file watcher errors instead of silently swallowing them
- Harden path traversal check in storage.rs: re-verify after
  canonicalization to catch symlink escapes
- Add Windows reserved device name handling (CON, NUL, etc.) to
  sanitize_filename
- Clean up stale .tmp files from interrupted atomic writes on startup

All 107 core tests pass.

https://claude.ai/code/session_01EnSrQsowc64rAwzD9BnJpc
This commit is contained in:
Claude 2026-04-06 10:41:03 +00:00
parent 4de300cb76
commit 85748f4c95
No known key found for this signature in database
3 changed files with 79 additions and 27 deletions

View file

@ -41,6 +41,13 @@ fn lock_state(state: &Mutex<AppState>) -> Result<std::sync::MutexGuard<'_, AppSt
state.lock().map_err(|e| format!("State lock poisoned: {}", e)) state.lock().map_err(|e| format!("State lock poisoned: {}", e))
} }
impl AppState {
/// Persist config to disk, converting errors to String for Tauri commands.
fn save_config(&self) -> Result<(), String> {
self.config.save_to_file(&self.config_path).map_err(|e| e.to_string())
}
}
/// Validate that a workspace path is a reasonable directory and not a system path. /// Validate that a workspace path is a reasonable directory and not a system path.
fn validate_workspace_path(path: &str) -> Result<(), String> { fn validate_workspace_path(path: &str) -> Result<(), String> {
let p = PathBuf::from(path); let p = PathBuf::from(path);
@ -138,7 +145,7 @@ fn get_config(state: State<'_, Mutex<AppState>>) -> Result<AppConfig, String> {
#[tauri::command] #[tauri::command]
fn save_config(state: State<'_, Mutex<AppState>>) -> Result<(), String> { fn save_config(state: State<'_, Mutex<AppState>>) -> Result<(), String> {
let s = lock_state(&state)?; let s = lock_state(&state)?;
s.config.save_to_file(&s.config_path).map_err(|e| e.to_string()) s.save_config()
} }
#[tauri::command] #[tauri::command]
@ -155,9 +162,7 @@ fn add_workspace(
.set_current_workspace(id) .set_current_workspace(id)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
s.repo = None; s.repo = None;
s.config s.save_config()
.save_to_file(&s.config_path.clone())
.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@ -170,9 +175,7 @@ fn set_current_workspace(
.set_current_workspace(id) .set_current_workspace(id)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
s.repo = None; s.repo = None;
s.config s.save_config()
.save_to_file(&s.config_path.clone())
.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@ -183,9 +186,7 @@ fn remove_workspace(
let mut s = lock_state(&state)?; let mut s = lock_state(&state)?;
s.config.remove_workspace(&id); s.config.remove_workspace(&id);
s.repo = None; s.repo = None;
s.config s.save_config()
.save_to_file(&s.config_path.clone())
.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@ -224,7 +225,7 @@ async fn rename_workspace(
ws.path = new_path; ws.path = new_path;
} }
s.repo = None; s.repo = None;
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?; s.save_config()?;
} }
WorkspaceMode::Webdav => { WorkspaceMode::Webdav => {
// Rename the remote folder via WebDAV MOVE // Rename the remote folder via WebDAV MOVE
@ -260,7 +261,7 @@ async fn rename_workspace(
ws.webdav_path = Some(new_remote_path); ws.webdav_path = Some(new_remote_path);
} }
s.repo = None; s.repo = None;
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?; s.save_config()?;
} }
} }
@ -521,9 +522,7 @@ fn set_webdav_config(
if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) { if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) {
ws.webdav_url = Some(webdav_url); ws.webdav_url = Some(webdav_url);
} }
s.config s.save_config()
.save_to_file(&s.config_path.clone())
.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@ -536,7 +535,7 @@ fn set_workspace_theme(
if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) { if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) {
ws.theme = theme; ws.theme = theme;
} }
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string()) s.save_config()
} }
#[tauri::command] #[tauri::command]
@ -549,7 +548,7 @@ fn set_sync_interval(
if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) { if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) {
ws.sync_interval_secs = interval_secs; ws.sync_interval_secs = interval_secs;
} }
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string()) s.save_config()
} }
/// A remote folder entry returned to the frontend. /// A remote folder entry returned to the frontend.
@ -693,7 +692,7 @@ fn add_webdav_workspace(
.and_then(|rest| rest.split('/').next()) .and_then(|rest| rest.split('/').next())
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?; s.save_config()?;
drop(s); drop(s);
let creds = app_handle.state::<Credentials<tauri::Wry>>(); let creds = app_handle.state::<Credentials<tauri::Wry>>();
creds.store(&domain, &username, &password)?; creds.store(&domain, &username, &password)?;
@ -785,7 +784,7 @@ async fn sync_workspace(
if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) { if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) {
ws.last_sync = Some(Utc::now()); ws.last_sync = Some(Utc::now());
} }
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?; s.save_config()?;
} }
Ok(result.into()) Ok(result.into())
@ -803,7 +802,10 @@ fn start_watcher(handle: tauri::AppHandle, path: PathBuf) {
let debouncer = new_debouncer( let debouncer = new_debouncer(
std::time::Duration::from_millis(500), std::time::Duration::from_millis(500),
move |events: Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>| { move |events: Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>| {
let Ok(events) = events else { return }; let Ok(events) = events else {
eprintln!("File watcher error: {:?}", events.unwrap_err());
return;
};
// Only care about data file changes // Only care about data file changes
let has_data_change = events.iter().any(|e| { let has_data_change = events.iter().any(|e| {
if e.kind != DebouncedEventKind::Any { return false; } if e.kind != DebouncedEventKind::Any { return false; }

View file

@ -126,7 +126,9 @@ pub fn retarget(name: String, path: String) -> Result<()> {
let id = resolve_name(&config, &name)?; let id = resolve_name(&config, &name)?;
// Update path // Update path
config.workspaces.get_mut(&id).unwrap().path = path_buf.clone(); config.workspaces.get_mut(&id)
.ok_or_else(|| anyhow::anyhow!("Workspace '{}' disappeared from config", name))?
.path = path_buf.clone();
save_config(&config)?; save_config(&config)?;
output::success(&format!("Workspace \"{}\" now points to {}", name, path_buf.display())); output::success(&format!("Workspace \"{}\" now points to {}", name, path_buf.display()));
@ -221,7 +223,9 @@ pub fn migrate(name: String, new_path: String) -> Result<()> {
} }
// Update config // Update config
config.workspaces.get_mut(&id).unwrap().path = new_path_buf.clone(); config.workspaces.get_mut(&id)
.ok_or_else(|| anyhow::anyhow!("Workspace '{}' disappeared from config", name))?
.path = new_path_buf.clone();
save_config(&config)?; save_config(&config)?;
output::success(&format!("Migrated {} items to {}", moved.len(), new_path_buf.display())); output::success(&format!("Migrated {} items to {}", moved.len(), new_path_buf.display()));

View file

@ -133,7 +133,33 @@ impl FileSystemStorage {
if !root_path.exists() { if !root_path.exists() {
return Err(Error::NotFound(format!("Path does not exist: {:?}", root_path))); return Err(Error::NotFound(format!("Path does not exist: {:?}", root_path)));
} }
Ok(Self { root_path }) let storage = Self { root_path };
storage.cleanup_stale_tmp_files();
Ok(storage)
}
/// Remove leftover .tmp files from interrupted atomic writes.
fn cleanup_stale_tmp_files(&self) {
let cleanup_dir = |dir: &Path| {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("tmp") {
let _ = fs::remove_file(&path);
}
}
}
};
// Clean root-level .tmp files
cleanup_dir(&self.root_path);
// Clean .tmp files inside list directories
if let Ok(entries) = fs::read_dir(&self.root_path) {
for entry in entries.flatten() {
if entry.path().is_dir() {
cleanup_dir(&entry.path());
}
}
}
} }
pub fn init(root_path: PathBuf) -> Result<Self> { pub fn init(root_path: PathBuf) -> Result<Self> {
@ -181,11 +207,19 @@ impl FileSystemStorage {
return Err(Error::InvalidData("Invalid list name: path traversal not allowed".to_string())); return Err(Error::InvalidData("Invalid list name: path traversal not allowed".to_string()));
} }
let path = self.root_path.join(name); let path = self.root_path.join(name);
// Verify resolved path stays within root // Verify resolved path stays within root.
// Always build canonical_path from canonical_root + filename to avoid TOCTOU
// races and symlink escapes (canonicalize resolves symlinks, so a symlink
// pointing outside the workspace would be caught).
let canonical_root = self.root_path.canonicalize() let canonical_root = self.root_path.canonicalize()
.map_err(Error::Io)?; .map_err(Error::Io)?;
let canonical_path = if path.exists() { let canonical_path = if path.exists() {
path.canonicalize().map_err(Error::Io)? let resolved = path.canonicalize().map_err(Error::Io)?;
// Re-check after canonicalization to catch symlinks pointing outside
if !resolved.starts_with(&canonical_root) {
return Err(Error::InvalidData("Invalid list name: path escapes workspace".to_string()));
}
resolved
} else { } else {
// Parent must exist and be canonicalizable (it's root_path) // Parent must exist and be canonicalizable (it's root_path)
canonical_root.join(path.file_name().unwrap_or_default()) canonical_root.join(path.file_name().unwrap_or_default())
@ -197,7 +231,7 @@ impl FileSystemStorage {
} }
fn sanitize_filename(name: &str) -> String { fn sanitize_filename(name: &str) -> String {
name.chars() let sanitized: String = name.chars()
.map(|c| match c { .map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
'\0'..='\x1f' => '_', '\0'..='\x1f' => '_',
@ -205,7 +239,19 @@ impl FileSystemStorage {
}) })
.collect::<String>() .collect::<String>()
.trim_matches(|c: char| c == '.' || c == ' ') .trim_matches(|c: char| c == '.' || c == ' ')
.to_string() .to_string();
// Reject Windows reserved device names (CON, PRN, AUX, NUL, COM0-9, LPT0-9)
let stem = sanitized.split('.').next().unwrap_or("").to_uppercase();
let is_reserved = matches!(stem.as_str(),
"CON" | "PRN" | "AUX" | "NUL"
| "COM0" | "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9"
| "LPT0" | "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9"
);
if is_reserved {
format!("_{}", sanitized)
} else {
sanitized
}
} }
fn task_file_path(&self, list_dir: &Path, task: &Task) -> PathBuf { fn task_file_path(&self, list_dir: &Path, task: &Task) -> PathBuf {