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:
parent
4de300cb76
commit
85748f4c95
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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()));
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue