fix(security): prevent arbitrary file system operations in migrate_data_dir

Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-08-09 10:44:22 +00:00
parent c958f30662
commit f6d12097b9
3 changed files with 44 additions and 20 deletions

View file

@ -5,8 +5,32 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use tauri::menu::{Menu, MenuItem}; use tauri::menu::{Menu, MenuItem};
struct MigrationState(Mutex<Option<PathBuf>>);
#[derive(Serialize)]
struct PickDataDirResult {
path: String,
has_documents: bool,
}
#[tauri::command]
fn pick_data_dir(app: AppHandle, state: tauri::State<'_, MigrationState>) -> Result<Option<PickDataDirResult>, String> {
if let Some(p) = app.dialog().file().blocking_pick_folder() {
let path = p.into_path().map_err(|e| e.to_string())?;
let has_docs = path.join("documents.json").exists();
*state.0.lock().unwrap() = Some(path.clone());
Ok(Some(PickDataDirResult {
path: path.to_string_lossy().into_owned(),
has_documents: has_docs,
}))
} else {
Ok(None)
}
}
use tauri::tray::TrayIconBuilder; use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Emitter, Manager, WindowEvent}; use tauri::{AppHandle, Emitter, Manager, WindowEvent};
use tauri_plugin_dialog::DialogExt; use tauri_plugin_dialog::DialogExt;
@ -537,11 +561,6 @@ fn open_backups_folder(app: AppHandle, dir: Option<String>) -> Result<(), String
// ---------- data folder migration // ---------- data folder migration
#[tauri::command]
fn data_dir_has_documents(dir: String) -> bool {
PathBuf::from(dir).join("documents.json").exists()
}
/// strategy: "move" (copy mine, delete originals), "overwrite" (same, target /// strategy: "move" (copy mine, delete originals), "overwrite" (same, target
/// had documents), "use_existing" (just switch — keep the target's files) /// had documents), "use_existing" (just switch — keep the target's files)
#[tauri::command] #[tauri::command]
@ -550,9 +569,15 @@ fn migrate_data_dir(
old_dir: Option<String>, old_dir: Option<String>,
new_dir: String, new_dir: String,
strategy: String, strategy: String,
state: tauri::State<'_, MigrationState>,
) -> Result<(), String> { ) -> Result<(), String> {
let old = docs_dir(&app, &old_dir); let approved_dir = state.0.lock().unwrap().take();
let new = PathBuf::from(&new_dir); let new = PathBuf::from(&new_dir);
if Some(new.clone()) != approved_dir {
return Err("Migration rejected: target directory was not explicitly chosen by the user.".into());
}
let old = docs_dir(&app, &old_dir);
fs::create_dir_all(&new).map_err(|e| e.to_string())?; fs::create_dir_all(&new).map_err(|e| e.to_string())?;
// canonicalize: "C:\Data" and "c:\data" are the same folder on Windows // canonicalize: "C:\Data" and "c:\data" are the same folder on Windows
let canon_old = fs::canonicalize(&old).unwrap_or_else(|_| old.clone()); let canon_old = fs::canonicalize(&old).unwrap_or_else(|_| old.clone());
@ -743,7 +768,7 @@ fn main() {
run_backups, run_backups,
backup_deleted_sheet, backup_deleted_sheet,
open_backups_folder, open_backups_folder,
data_dir_has_documents, pick_data_dir,
migrate_data_dir, migrate_data_dir,
fetch_rates, fetch_rates,
fetch_historical_rates, fetch_historical_rates,
@ -758,6 +783,7 @@ fn main() {
write_image_file write_image_file
]) ])
.setup(|app| { .setup(|app| {
app.manage(MigrationState(Mutex::new(None)));
let handle = app.handle().clone(); let handle = app.handle().clone();
let show = MenuItem::with_id(app, "show", "Show / Hide", true, None::<&str>)?; let show = MenuItem::with_id(app, "show", "Show / Hide", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit Summarum", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "Quit Summarum", true, None::<&str>)?;

View file

@ -8,7 +8,7 @@ import {
fetchRates, fetchMarketData, fetchHistoricalRates, loadExtensionScripts, openExtensionsFolder, isTauri, fetchRates, fetchMarketData, fetchHistoricalRates, loadExtensionScripts, openExtensionsFolder, isTauri,
getLaunchFile, onOpenFile, onFileDrop, getLaunchFile, onOpenFile, onFileDrop,
setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder,
chooseFolder, dataDirHasDocuments, migrateDataDir, pickDataDir, migrateDataDir,
} from "./storage"; } from "./storage";
import type { LineResult } from "./engine"; import type { LineResult } from "./engine";
import { setLang, detectLang, t } from "./i18n"; import { setLang, detectLang, t } from "./i18n";
@ -404,10 +404,12 @@ function bindDataDirSettings(): void {
}; };
renderDataDir(); renderDataDir();
dataDirBtn.addEventListener("click", async () => { dataDirBtn.addEventListener("click", async () => {
const picked = await chooseFolder(); const res = await pickDataDir();
if (!picked || picked === settings.dataDir) return; if (!res) return;
const { path: picked, hasDocuments } = res;
if (picked === settings.dataDir) return;
let strategy: "move" | "overwrite" | "use_existing" = "move"; let strategy: "move" | "overwrite" | "use_existing" = "move";
if (await dataDirHasDocuments(picked)) { if (hasDocuments) {
const ans = await askModal(t("folderConflict"), t("useExisting"), t("replaceMine")); const ans = await askModal(t("folderConflict"), t("useExisting"), t("replaceMine"));
if (ans === null) return; if (ans === null) return;
strategy = ans === "a" ? "use_existing" : "overwrite"; strategy = ans === "a" ? "use_existing" : "overwrite";

View file

@ -167,16 +167,12 @@ export async function openBackupsFolder(dataDir: string): Promise<void> {
if (isTauri()) await invoke("open_backups_folder", { dir: dirArg(dataDir) }); if (isTauri()) await invoke("open_backups_folder", { dir: dirArg(dataDir) });
} }
export async function chooseFolder(): Promise<string | null> { export async function pickDataDir(): Promise<{ path: string, hasDocuments: boolean } | null> {
if (!isTauri()) return null; if (!isTauri()) return null;
const { open } = await import("@tauri-apps/plugin-dialog"); return invoke<{ path: string, has_documents: boolean } | null>("pick_data_dir").then(res => {
const picked = await open({ directory: true, multiple: false }); if (!res) return null;
return typeof picked === "string" ? picked : null; return { path: res.path, hasDocuments: res.has_documents };
} });
export async function dataDirHasDocuments(dir: string): Promise<boolean> {
if (!isTauri()) return false;
return invoke<boolean>("data_dir_has_documents", { dir });
} }
export type MigrateStrategy = "move" | "overwrite" | "use_existing"; export type MigrateStrategy = "move" | "overwrite" | "use_existing";