fix: handle filename collisions in create flows
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user