fix: harden core data integrity — move_task rollback, path traversal, migration safety

Add rollback to move_task: if delete-from-source fails after write-to-
destination, clean up the duplicate. Reject list names with path separators
or '..' to prevent traversal; canonicalize() failures now return errors
instead of silently falling back to unchecked paths. Add validation and
rollback to CLI workspace migration: check destination is empty, track
moved files, and reverse on failure.
This commit is contained in:
Tristan Michael 2026-04-02 08:58:27 -07:00
parent de11e0a8c3
commit fa1125bfeb
4 changed files with 59 additions and 33 deletions

1
Cargo.lock generated
View file

@ -1000,6 +1000,7 @@ dependencies = [
"tokio", "tokio",
"uuid", "uuid",
"wiremock", "wiremock",
"zeroize",
] ]
[[package]] [[package]]

View file

@ -168,16 +168,25 @@ pub fn migrate(name: String, new_path: String) -> Result<()> {
return Ok(()); return Ok(());
} }
// Validate destination
if old_path == new_path_buf {
anyhow::bail!("Source and destination paths are the same");
}
if new_path_buf.exists() && new_path_buf.read_dir()?.next().is_some() {
anyhow::bail!("Destination directory '{}' already contains files", new_path_buf.display());
}
// Create destination directory // Create destination directory
std::fs::create_dir_all(&new_path_buf)?; std::fs::create_dir_all(&new_path_buf)?;
// Move files // Move files, tracking what was moved for rollback
output::info("Moving files..."); output::info("Moving files...");
let entries = std::fs::read_dir(&old_path)?; let entries: Vec<_> = std::fs::read_dir(&old_path)?
let mut count = 0; .collect::<std::result::Result<Vec<_>, _>>()?;
let mut moved: Vec<(std::path::PathBuf, std::path::PathBuf)> = Vec::new();
for entry in entries { let move_result: Result<()> = (|| {
let entry = entry?; for entry in &entries {
let file_name = entry.file_name(); let file_name = entry.file_name();
let dest = new_path_buf.join(&file_name); let dest = new_path_buf.join(&file_name);
@ -185,16 +194,33 @@ pub fn migrate(name: String, new_path: String) -> Result<()> {
let mut options = fs_extra::dir::CopyOptions::new(); let mut options = fs_extra::dir::CopyOptions::new();
options.copy_inside = true; options.copy_inside = true;
fs_extra::dir::move_dir(entry.path(), &new_path_buf, &options)?; fs_extra::dir::move_dir(entry.path(), &new_path_buf, &options)?;
output::item(&format!("Moved {}/", file_name.to_string_lossy()));
} else { } else {
std::fs::rename(entry.path(), dest)?; std::fs::rename(entry.path(), &dest)?;
}
moved.push((entry.path(), dest));
output::item(&format!("Moved {}", file_name.to_string_lossy())); output::item(&format!("Moved {}", file_name.to_string_lossy()));
} }
count += 1; Ok(())
})();
if let Err(e) = move_result {
output::error(&format!("Migration failed: {}. Rolling back...", e));
for (src, dest) in moved.into_iter().rev() {
if dest.exists() {
if dest.is_dir() {
let mut options = fs_extra::dir::CopyOptions::new();
options.copy_inside = true;
let _ = fs_extra::dir::move_dir(&dest, &old_path, &options);
} else {
let _ = std::fs::rename(&dest, &src);
}
}
}
anyhow::bail!("Migration failed and was rolled back: {}", e);
} }
// Remove old directory if empty // Remove old directory if empty
if old_path.read_dir()?.next().is_none() { if old_path.exists() && old_path.read_dir()?.next().is_none() {
std::fs::remove_dir(&old_path)?; std::fs::remove_dir(&old_path)?;
} }
@ -202,7 +228,7 @@ pub fn migrate(name: String, new_path: String) -> Result<()> {
config.add_workspace(name.clone(), WorkspaceConfig::new(new_path_buf.clone())); config.add_workspace(name.clone(), WorkspaceConfig::new(new_path_buf.clone()));
save_config(&config)?; save_config(&config)?;
output::success(&format!("Migrated {} items to {}", count, new_path_buf.display())); output::success(&format!("Migrated {} items to {}", moved.len(), new_path_buf.display()));
output::success(&format!("Workspace \"{}\" now points to {}", name, new_path_buf.display())); output::success(&format!("Workspace \"{}\" now points to {}", name, new_path_buf.display()));
Ok(()) Ok(())

View file

@ -75,7 +75,11 @@ impl TaskRepository {
pub fn move_task(&mut self, from_list_id: Uuid, to_list_id: Uuid, task_id: Uuid) -> Result<()> { pub fn move_task(&mut self, from_list_id: Uuid, to_list_id: Uuid, task_id: Uuid) -> Result<()> {
let task = self.storage.read_task(from_list_id, task_id)?; let task = self.storage.read_task(from_list_id, task_id)?;
self.storage.write_task(to_list_id, &task)?; self.storage.write_task(to_list_id, &task)?;
self.storage.delete_task(from_list_id, task_id)?; // If delete from source fails, roll back by removing the copy from destination
if let Err(e) = self.storage.delete_task(from_list_id, task_id) {
let _ = self.storage.delete_task(to_list_id, task_id);
return Err(e);
}
Ok(()) Ok(())
} }

View file

@ -150,27 +150,22 @@ impl FileSystemStorage {
} }
fn list_dir_path_by_name(&self, name: &str) -> Result<PathBuf> { fn list_dir_path_by_name(&self, name: &str) -> Result<PathBuf> {
let path = self.root_path.join(name); // Reject names containing path separators or traversal components
// Prevent path traversal: resolved path must stay within root if name.contains('/') || name.contains('\\') || name == ".." || name.starts_with("../") || name.starts_with("..\\") {
let canonical_root = self.root_path.canonicalize() return Err(Error::InvalidData("Invalid list name: path traversal not allowed".to_string()));
.unwrap_or_else(|_| self.root_path.clone());
let canonical_path = if path.exists() {
path.canonicalize().unwrap_or_else(|_| path.clone())
} else {
// For non-existent paths, normalize by resolving the parent
if let Some(parent) = path.parent() {
let canonical_parent = if parent.exists() {
parent.canonicalize().unwrap_or_else(|_| parent.to_path_buf())
} else {
parent.to_path_buf()
};
canonical_parent.join(path.file_name().unwrap_or_default())
} else {
path.clone()
} }
let path = self.root_path.join(name);
// Verify resolved path stays within root
let canonical_root = self.root_path.canonicalize()
.map_err(Error::Io)?;
let canonical_path = if path.exists() {
path.canonicalize().map_err(Error::Io)?
} else {
// Parent must exist and be canonicalizable (it's root_path)
canonical_root.join(path.file_name().unwrap_or_default())
}; };
if !canonical_path.starts_with(&canonical_root) { if !canonical_path.starts_with(&canonical_root) {
return Err(Error::InvalidData(format!("Invalid list name: path escapes workspace"))); return Err(Error::InvalidData("Invalid list name: path escapes workspace".to_string()));
} }
Ok(path) Ok(path)
} }