fix: replace write_text_file with backend dialog

Replaces `write_text_file` and `write_image_file` with backend commands
`save_text_file_dialog` and `save_image_file_dialog`. Instead of allowing
the frontend to pass an arbitrary absolute path, the Tauri backend now invokes
the OS save dialog directly, eliminating the risk of arbitrary file write
if the frontend is compromised.

Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-07-08 12:02:11 +00:00
parent 984275711a
commit 21f7726316
6 changed files with 54 additions and 32 deletions

10
package-lock.json generated
View file

@ -17,6 +17,7 @@
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-global-shortcut": "^2.2.0",
"@tauri-apps/plugin-opener": "^2.2.0",
"@tauri-apps/plugin-process": "^2.3.1",
@ -1218,6 +1219,15 @@
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-fs": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz",
"integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-global-shortcut": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.2.tgz",

View file

@ -23,6 +23,7 @@
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-global-shortcut": "^2.2.0",
"@tauri-apps/plugin-opener": "^2.2.0",
"@tauri-apps/plugin-process": "^2.3.1",

View file

@ -38,20 +38,41 @@ fn safe_name(name: &str) -> bool {
}
#[tauri::command]
fn write_text_file(path: String, contents: String) -> Result<(), String> {
write_atomic(std::path::Path::new(&path), &contents)
async fn save_text_file_dialog(app: tauri::AppHandle, contents: String, is_sum: bool) -> Result<bool, String> {
use tauri_plugin_dialog::DialogExt;
let dialog = app.dialog().file();
let dialog = if is_sum {
dialog.add_filter("Summarum Sheet", &["sum"])
} else {
dialog.add_filter("Text file", &["txt"])
};
if let Some(path) = dialog.blocking_save_file() {
let path = path.into_path().map_err(|_| "invalid path".to_string())?;
write_atomic(&path, &contents)?;
Ok(true)
} else {
Ok(false)
}
}
#[tauri::command]
fn write_image_file(path: String, data_base64: String) -> Result<(), String> {
async fn save_image_file_dialog(app: tauri::AppHandle, data_base64: String) -> Result<bool, String> {
use tauri_plugin_dialog::DialogExt;
use std::io::Write;
let bytes = base64_decode(&data_base64)?;
let p = std::path::Path::new(&path);
let tmp = p.with_extension("tmp");
std::fs::File::create(&tmp)
.and_then(|mut f| f.write_all(&bytes))
.map_err(|e| e.to_string())?;
fs::rename(&tmp, p).map_err(|e| e.to_string())
if let Some(path) = app.dialog().file().add_filter("PNG Image", &["png"]).blocking_save_file() {
let path = path.into_path().map_err(|_| "invalid path".to_string())?;
let bytes = base64_decode(&data_base64)?;
let tmp = path.with_extension("tmp");
std::fs::File::create(&tmp)
.and_then(|mut f| f.write_all(&bytes))
.map_err(|e| e.to_string())?;
fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
Ok(true)
} else {
Ok(false)
}
}
fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
@ -712,8 +733,8 @@ fn main() {
read_text_file,
exit_app,
is_hidden_launch,
write_text_file,
write_image_file
save_text_file_dialog,
save_image_file_dialog
])
.setup(|app| {
let handle = app.handle().clone();

View file

@ -907,17 +907,11 @@ async function boot(): Promise<void> {
} else if (action === "print") {
window.print();
} else if (isTauri()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const { invoke } = await import("@tauri-apps/api/core");
const isSum = action === "save-sum";
const path = await save({
filters: isSum
? [{ name: "Summarum Sheet", extensions: ["sum"] }]
: [{ name: "Text file", extensions: ["txt"] }],
});
if (path) {
const content = isSum ? editor.getText() : editor.getSheetWithResults();
await invoke("write_text_file", { path, contents: content });
const content = isSum ? editor.getText() : editor.getSheetWithResults();
const saved = await invoke("save_text_file_dialog", { contents: content, isSum });
if (saved) {
toast(t("saved"));
}
}

View file

@ -234,9 +234,9 @@ export async function fetchHistoricalRates(date: string): Promise<Record<string,
// ---------- image export
export async function writeImageFile(path: string, dataBase64: string): Promise<void> {
if (!isTauri()) return;
await invoke("write_image_file", { path, dataBase64 });
export async function writeImageFile(dataBase64: string): Promise<boolean> {
if (!isTauri()) return false;
return await invoke<boolean>("save_image_file_dialog", { dataBase64 });
}
// ---------- market data

View file

@ -58,14 +58,10 @@ export async function exportSheetImage(deps: ExportImageDeps): Promise<void> {
} catch {
// clipboard API failed — save to file
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({ filters: [{ name: "PNG Image", extensions: ["png"] }] });
if (path) {
const buf = await blob.arrayBuffer();
const b64 = bytesToBase64(new Uint8Array(buf));
await writeImageFile(path, b64);
toast(t("saved"));
}
const buf = await blob.arrayBuffer();
const b64 = bytesToBase64(new Uint8Array(buf));
const saved = await writeImageFile(b64);
if (saved) toast(t("saved"));
} catch {
toast(t("imageFailed"));
}