fix: handle filename collisions in create flows

This commit is contained in:
lucaronin
2026-04-22 22:37:01 +02:00
parent a27a70e552
commit bf13eed3ab
18 changed files with 562 additions and 171 deletions

View File

@@ -139,6 +139,22 @@ mod tests {
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
#[test]
fn test_create_note_content_rejects_traversal_outside_active_vault() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let escape_path = vault_path.join("../outside.md");
let err = create_note_content(
escape_path,
"# Outside\n".to_string(),
vault_path_arg(vault_path),
)
.expect_err("expected traversal create to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
#[test]
fn test_create_vault_folder_rejects_escape_path() {
let dir = tempfile::TempDir::new().unwrap();

View File

@@ -39,6 +39,22 @@ fn with_requested_root_path<T>(
with_requested_root(raw_vault_path.as_ref(), action)
}
fn with_writable_note_path<T>(
path: PathBuf,
vault_path: Option<PathBuf>,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
with_validated_path(
path.to_string_lossy().as_ref(),
vault_path
.as_ref()
.map(|value| value.to_string_lossy())
.as_deref(),
ValidatedPathMode::Writable,
action,
)
}
#[tauri::command]
pub fn get_note_content(path: PathBuf, vault_path: Option<PathBuf>) -> Result<String, String> {
with_note_path(
@@ -55,15 +71,20 @@ pub fn save_note_content(
content: String,
vault_path: Option<PathBuf>,
) -> Result<(), String> {
with_validated_path(
path.to_string_lossy().as_ref(),
vault_path
.as_ref()
.map(|value| value.to_string_lossy())
.as_deref(),
ValidatedPathMode::Writable,
|validated_path| vault::save_note_content(validated_path, &content),
)
with_writable_note_path(path, vault_path, |validated_path| {
vault::save_note_content(validated_path, &content)
})
}
#[tauri::command]
pub fn create_note_content(
path: PathBuf,
content: String,
vault_path: Option<PathBuf>,
) -> Result<(), String> {
with_writable_note_path(path, vault_path, |validated_path| {
vault::create_note_content(validated_path, &content)
})
}
#[tauri::command]

View File

@@ -196,6 +196,7 @@ macro_rules! app_invoke_handler {
commands::list_vault,
commands::list_vault_folders,
commands::get_note_content,
commands::create_note_content,
commands::save_note_content,
commands::update_frontmatter,
commands::delete_frontmatter_property,

View File

@@ -1,4 +1,5 @@
use std::fs;
use std::io::{ErrorKind, Write};
use std::path::Path;
use std::time::UNIX_EPOCH;
@@ -63,3 +64,25 @@ pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
validate_save_path(file_path, path)?;
fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e))
}
/// Create a new note file without overwriting any existing file.
pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
let file_path = Path::new(path);
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
}
}
validate_save_path(file_path, path)?;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(file_path)
.map_err(|e| match e.kind() {
ErrorKind::AlreadyExists => format!("File already exists: {}", path),
_ => format!("Failed to create {}: {}", path, e),
})?;
file.write_all(content.as_bytes())
.map_err(|e| format!("Failed to save {}: {}", path, e))
}

View File

@@ -20,7 +20,7 @@ pub use config_seed::{
seed_config_files, AiGuidanceFileState, VaultAiGuidanceStatus,
};
pub use entry::{FolderNode, VaultEntry};
pub use file::{get_note_content, save_note_content};
pub use file::{create_note_content, get_note_content, save_note_content};
pub use folders::{delete_folder, rename_folder, FolderRenameResult};
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
pub use image::{copy_image_to_vault, save_image};

View File

@@ -165,3 +165,29 @@ fn test_save_note_content_deeply_nested_new_directory() {
assert!(path.exists());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
#[test]
fn test_create_note_content_creates_parent_directory() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("new-type/briefing.md");
let content = "---\ntitle: Briefing\ntype: Note\n---\n";
assert!(!path.parent().unwrap().exists());
create_note_content(path.to_str().unwrap(), content).unwrap();
assert!(path.exists());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
#[test]
fn test_create_note_content_rejects_existing_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("briefing.md");
fs::write(&path, "# Existing\n").unwrap();
let err = create_note_content(path.to_str().unwrap(), "# Replacement\n")
.expect_err("expected create-only write to reject collisions");
assert!(err.contains("already exists"));
assert_eq!(fs::read_to_string(&path).unwrap(), "# Existing\n");
}