From c03c38d49a1e2155963908f904daa3d17d6be4fe Mon Sep 17 00:00:00 2001 From: u8array Date: Tue, 21 Jul 2026 19:50:47 +0200 Subject: [PATCH] feat(connector): Rust-owned path grants for the sqlite/excel commands db/excel commands refuse paths that did not come from the native pick commands; sqlite grants persist, excel grants are session-only. --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 + src-tauri/src/db.rs | 22 +- src-tauri/src/excel.rs | 22 +- src-tauri/src/lib.rs | 10 + src-tauri/src/scope.rs | 370 ++++++++++++++++++ .../PrinterSettings/DataSourcesTab.tsx | 53 ++- src/hooks/useExcelImportActions.ts | 6 +- src/lib/db.ts | 16 + src/lib/excel.ts | 7 + src/lib/fileDialogs.ts | 9 - 11 files changed, 494 insertions(+), 25 deletions(-) create mode 100644 src-tauri/src/scope.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e244f524..72f1f03f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6206,6 +6206,7 @@ version = "0.1.0" dependencies = [ "calamine", "chrono", + "dunce", "keyring", "libc", "nusb", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d4f7e4a8..9e816377 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -45,6 +45,9 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] } # Typed errors inside the db/excel connector (stringified only at the IPC edge). # Already transitive via sqlx. thiserror = "1" +# Canonicalize without the \\?\ prefix (scope.rs): stored grants must match +# user-visible paths or revoke's literal fallback can't find them. +dunce = "1" [target.'cfg(unix)'.dependencies] # Linux: O_NONBLOCK for the usblp read-back fd (AsyncFd needs a non-blocking diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index ed666585..d9a7a759 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -46,6 +46,8 @@ enum DbError { PasswordMalformed, #[error("sqlite has no password")] SqliteNoPassword, + #[error("file access not granted; re-select the database file in the profile")] + PathNotAllowed, #[error(transparent)] Join(#[from] tauri::Error), } @@ -254,6 +256,8 @@ async fn keychain_password(profile_id: &str, endpoint: &str) -> Result Result { // Resolved BEFORE the network timeout: a keychain read can block on an OS // permission prompt, which must not eat the connect budget. @@ -507,8 +511,21 @@ async fn run_list_tables(spec: &DbSpec) -> Result, DbError> { out } +/// Refuse a sqlite path the user never granted via the native pick command +/// (scope::PathGrants); network specs carry no path. +fn check_path_scope(app: &tauri::AppHandle, spec: &DbSpec) -> Result<(), DbError> { + use tauri::Manager; + match spec { + DbSpec::Sqlite { path } if !app.state::().is_granted(path) => { + Err(DbError::PathNotAllowed) + } + _ => Ok(()), + } +} + #[tauri::command] -pub async fn db_list_tables(spec: DbSpec) -> Result, String> { +pub async fn db_list_tables(app: tauri::AppHandle, spec: DbSpec) -> Result, String> { + check_path_scope(&app, &spec).map_err(|e| e.to_string())?; run_list_tables(&spec).await.map_err(|e| e.to_string()) } @@ -525,7 +542,8 @@ async fn run_fetch(spec: &DbSpec, table: &str) -> Result { } #[tauri::command] -pub async fn db_fetch(spec: DbSpec, table: String) -> Result { +pub async fn db_fetch(app: tauri::AppHandle, spec: DbSpec, table: String) -> Result { + check_path_scope(&app, &spec).map_err(|e| e.to_string())?; run_fetch(&spec, &table).await.map_err(|e| e.to_string()) } diff --git a/src-tauri/src/excel.rs b/src-tauri/src/excel.rs index 8f1f389e..e23d5671 100644 --- a/src-tauri/src/excel.rs +++ b/src-tauri/src/excel.rs @@ -43,6 +43,8 @@ enum ExcelError { TooLargeUncompressed, #[error("empty sheet: {0}")] EmptySheet(String), + #[error("file access not granted; re-select the file")] + PathNotAllowed, #[error(transparent)] Join(#[from] tauri::Error), } @@ -77,11 +79,22 @@ async fn timed_read( parsed? } +/// Refuse a path the user never granted via the native pick command +/// (scope::PathGrants). +fn check_path_scope(app: &tauri::AppHandle, path: &str) -> Result<(), ExcelError> { + use tauri::Manager; + if !app.state::().is_granted(path) { + return Err(ExcelError::PathNotAllowed); + } + Ok(()) +} + // calamine parses under the release panic="abort" profile, so a panic on a // crafted/corrupt workbook aborts the app instead of returning Err. Accepted; // check_size + the dialog-only pick keep realistic inputs benign. #[tauri::command] -pub async fn excel_list_sheets(path: String) -> Result, String> { +pub async fn excel_list_sheets(app: tauri::AppHandle, path: String) -> Result, String> { + check_path_scope(&app, &path).map_err(|e| e.to_string())?; timed_read(tauri::async_runtime::spawn_blocking( move || -> Result, ExcelError> { check_size(&path)?; @@ -94,7 +107,12 @@ pub async fn excel_list_sheets(path: String) -> Result, String> { } #[tauri::command] -pub async fn excel_fetch(path: String, sheet: String) -> Result { +pub async fn excel_fetch( + app: tauri::AppHandle, + path: String, + sheet: String, +) -> Result { + check_path_scope(&app, &path).map_err(|e| e.to_string())?; timed_read(tauri::async_runtime::spawn_blocking( move || -> Result { check_size(&path)?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 20080aea..1c27087b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod db; mod excel; mod mcp; mod print; +mod scope; mod transport; mod usb; @@ -13,6 +14,12 @@ use tauri::Manager; pub fn run() { let builder = tauri::Builder::default() .manage(mcp::McpState::default()) + // In setup, not manage(): loading the persisted grants needs the app paths. + .setup(|app| { + let grants = scope::PathGrants::load(app.handle()); + app.manage(grants); + Ok(()) + }) .invoke_handler(tauri::generate_handler![ print::send_zpl_tcp, print::query_zpl_tcp, @@ -27,6 +34,9 @@ pub fn run() { db::db_set_password, excel::excel_list_sheets, excel::excel_fetch, + scope::pick_sqlite_file, + scope::pick_excel_file, + scope::revoke_db_path, credentials::credential_get, credentials::credential_set, credentials::credential_delete, diff --git a/src-tauri/src/scope.rs b/src-tauri/src/scope.rs new file mode 100644 index 00000000..3606cbcb --- /dev/null +++ b/src-tauri/src/scope.rs @@ -0,0 +1,370 @@ +//! Rust-owned file authority for the data-source commands: paths become +//! usable only through the native pick commands below, never from webview +//! input. Sqlite grants persist (saved profiles reconnect across restarts); +//! excel grants are session-only like any other one-shot import. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use tauri::Manager; +use tauri_plugin_dialog::DialogExt; + +#[derive(Default)] +pub struct PathGrants { + session: Mutex>, + persistent: Mutex>, + /// Store file, resolved once at load; None = in-memory only (best-effort). + file: Option, +} + +impl PathGrants { + pub fn load(app: &tauri::AppHandle) -> Self { + let file = grants_file(app); + Self { + persistent: Mutex::new(file.as_deref().map(read_grants).unwrap_or_default()), + file, + ..Self::default() + } + } + + /// Grant, check, and revoke all compare through `canon`, so `..`/symlink + /// spellings can't dodge or spoof a grant. + #[must_use] + pub fn is_granted(&self, path: &str) -> bool { + let probe = match canon(path) { + Ok(canon) => canon, + // Unresolvable (deleted file, offline share): match the granted spelling + // literally so the command surfaces the real IO error, not a misleading + // "not granted". Nothing readable is granted; unknown spellings stay denied. + Err(_) => PathBuf::from(path), + }; + self.session.lock().unwrap().contains(&probe) + || self.persistent.lock().unwrap().contains(&probe) + } + + fn grant(&self, kind: GrantKind, canon: PathBuf) { + match kind { + GrantKind::Persistent => { + let mut set = self.persistent.lock().unwrap(); + set.insert(canon); + self.persist(&set); + } + GrantKind::Session => { + self.session.lock().unwrap().insert(canon); + } + } + } + + fn revoke(&self, path: &str) { + let mut set = self.persistent.lock().unwrap(); + if revoke_from(&mut set, path) { + self.persist(&set); + } + } + + /// Called under the persistent lock so store writes can't interleave. + fn persist(&self, set: &HashSet) { + if let Some(file) = &self.file { + write_grants(file, set); + } + } +} + +/// The one canonicalizer for grant/check/revoke; all sides must agree +/// byte-for-byte or a grant becomes unfindable. dunce strips Windows' +/// `\\?\` prefix so stored forms equal user-visible profile paths. +fn canon(path: impl AsRef) -> std::io::Result { + dunce::canonicalize(path) +} + +fn grants_file(app: &tauri::AppHandle) -> Option { + app + .path() + .app_config_dir() + .ok() + .map(|d| d.join("db-path-grants.json")) +} + +fn read_grants(file: &Path) -> HashSet { + std::fs::read_to_string(file) + .ok() + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default() +} + +fn write_grants(file: &Path, grants: &HashSet) { + let Ok(json) = serde_json::to_vec(grants) else { + return; + }; + if let Some(dir) = file.parent() { + let _ = std::fs::create_dir_all(dir); + } + // Best-effort: a failed write only costs a re-pick after the next restart. + let _ = std::fs::write(file, json); +} + +/// Whether a granted path outlives the session. Sqlite profiles reconnect +/// across restarts (persist); excel is a one-shot import (session). +enum GrantKind { + Session, + Persistent, +} + +/// Native pick on the blocking pool (the dialog blocks its calling thread), +/// then grant the canonicalized path. Returns the canonical path (what the +/// profile stores), None on cancel. +async fn pick_and_grant( + app: tauri::AppHandle, + filter_name: &'static str, + extensions: &'static [&'static str], + kind: GrantKind, + suggest: Option, +) -> Result, String> { + let dialog_app = app.clone(); + let picked = tauri::async_runtime::spawn_blocking(move || { + let mut dialog = dialog_app + .dialog() + .file() + .add_filter(filter_name, extensions); + // Preseed with the profile's current path: a re-pick (upgrade migration, + // moved file) is then a one-click confirm. Untrusted input is fine here, + // it only positions the dialog; granted is whatever the user confirms. + if let Some(prev) = suggest.as_deref().map(Path::new) { + if let Some(dir) = prev.parent() { + dialog = dialog.set_directory(dir); + } + if let Some(name) = prev.file_name() { + dialog = dialog.set_file_name(name.to_string_lossy()); + } + } + dialog.blocking_pick_file() + }) + .await + .map_err(|e| e.to_string())?; + let Some(picked) = picked else { + return Ok(None); + }; + let path = picked.into_path().map_err(|e| e.to_string())?; + let canon = canon(&path).map_err(|e| e.to_string())?; + // The profile stores this exact spelling (see canon). + let display = canon.to_string_lossy().into_owned(); + app.state::().grant(kind, canon); + Ok(Some(display)) +} + +#[tauri::command] +pub async fn pick_sqlite_file( + app: tauri::AppHandle, + suggest: Option, +) -> Result, String> { + pick_and_grant( + app, + "SQLite", + &["sqlite", "sqlite3", "db", "db3"], + GrantKind::Persistent, + suggest, + ) + .await +} + +#[tauri::command] +pub async fn pick_excel_file(app: tauri::AppHandle) -> Result, String> { + pick_and_grant( + app, + "Excel", + &["xlsx", "xlsm", "xls", "ods"], + GrantKind::Session, + None, + ) + .await +} + +/// Webview-callable because revocation only shrinks authority (worst case is +/// a re-pick); keeps the persisted set from ratcheting up forever. `keep` = +/// still-referenced paths, compared canonically HERE so respellings survive. +#[tauri::command] +pub fn revoke_db_path(app: tauri::AppHandle, path: String, keep: Vec) { + if still_referenced(&keep, &path) { + return; + } + app.state::().revoke(&path); +} + +/// Literal compare catches dead paths (both sides uncanonicalizable), +/// same_file catches a live sibling under another spelling. +fn still_referenced(keep: &[String], path: &str) -> bool { + keep.iter().any(|k| k == path || same_file(k, path)) +} + +/// False when either side no longer canonicalizes: a dead path then goes +/// through the revoke's literal fallback instead of being kept. +fn same_file(a: &str, b: &str) -> bool { + match (canon(a), canon(b)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + +/// Stored forms are canonical; the literal fallback covers a file already +/// deleted (no longer canonicalizable) that was stored under this spelling. +fn revoke_from(set: &mut HashSet, path: &str) -> bool { + match canon(path) { + Ok(canon) => set.remove(&canon), + Err(_) => set.remove(Path::new(path)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join("zplab-path-grants"); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join(name); + std::fs::write(&file, b"x").unwrap(); + file + } + + #[test] + fn ungranted_paths_are_refused() { + let grants = PathGrants::default(); + // Existing but never picked: the forged-IPC case. + assert!(!grants.is_granted(&scratch("data.db").to_string_lossy())); + assert!(!grants.is_granted("C:/missing/nowhere.db")); + } + + #[test] + fn granted_paths_pass_under_any_spelling() { + let grants = PathGrants::default(); + let file = scratch("granted.db"); + let canon = canon(&file).unwrap(); + grants.session.lock().unwrap().insert(canon); + assert!(grants.is_granted(&file.to_string_lossy())); + // A `..` respelling of the same file resolves to the same grant. + let dodged = file + .parent() + .unwrap() + .join("..") + .join("zplab-path-grants") + .join("granted.db"); + assert!(grants.is_granted(&dodged.to_string_lossy())); + assert!(!grants.is_granted(&scratch("other.db").to_string_lossy())); + } + + #[test] + fn persisted_grants_survive_a_reload() { + let file = scratch("reloaded.db"); + let canon = canon(&file).unwrap(); + let store = std::env::temp_dir() + .join("zplab-path-grants") + .join("store.json"); + let mut set = HashSet::new(); + set.insert(canon); + write_grants(&store, &set); + // The restart path: a fresh state built from the written file. + let reloaded = PathGrants { + persistent: Mutex::new(read_grants(&store)), + ..PathGrants::default() + }; + assert!(reloaded.is_granted(&file.to_string_lossy())); + } + + #[test] + fn revoke_removes_the_grant_under_any_spelling() { + let file = scratch("revoke.db"); + let canon = canon(&file).unwrap(); + let mut set = HashSet::new(); + set.insert(canon.clone()); + // Non-canonical spelling of an existing file still hits the stored form. + assert!(revoke_from(&mut set, &file.to_string_lossy())); + assert!(set.is_empty()); + // Deleted file: the literal fallback removes a literally-stored entry. + set.insert(PathBuf::from("C:/gone/old.db")); + assert!(revoke_from(&mut set, "C:/gone/old.db")); + assert!(set.is_empty()); + } + + #[test] + fn same_file_matches_across_spellings_and_fails_closed() { + let file = scratch("same.db"); + let respelled = file + .parent() + .unwrap() + .join("..") + .join("zplab-path-grants") + .join("same.db"); + assert!(same_file( + &file.to_string_lossy(), + &respelled.to_string_lossy() + )); + assert!(!same_file( + &file.to_string_lossy(), + &scratch("other.db").to_string_lossy() + )); + // Dead old path: not "same", so the replace flow revokes it. + assert!(!same_file("C:/gone/old.db", &file.to_string_lossy())); + } + + #[test] + fn a_granted_then_deleted_file_stays_granted_under_its_stored_spelling() { + // The command then proceeds and surfaces the real IO error ("unable to + // open"), not a misleading PathNotAllowed. + let file = scratch("vanished.db"); + let stored = canon(&file).unwrap(); + let ui_path = stored.to_string_lossy().into_owned(); + let grants = PathGrants::default(); + grants.persistent.lock().unwrap().insert(stored); + std::fs::remove_file(&file).unwrap(); + assert!(grants.is_granted(&ui_path)); + // A never-granted dead path still fails closed. + assert!(!grants.is_granted("C:/missing/nowhere.db")); + } + + #[test] + fn revoke_finds_a_deleted_files_stored_entry() { + // Grant while the file exists, then delete it: the stored canonical form + // equals the profile path (pick returns it), so the literal fallback hits. + let file = scratch("deleted.db"); + let stored = canon(&file).unwrap(); + let ui_path = stored.to_string_lossy().into_owned(); + let mut set = HashSet::new(); + set.insert(stored); + std::fs::remove_file(&file).unwrap(); + assert!(revoke_from(&mut set, &ui_path)); + assert!(set.is_empty()); + } + + #[test] + fn still_referenced_matches_respellings_and_dead_literals() { + let file = scratch("shared.db"); + let respelled = file + .parent() + .unwrap() + .join("..") + .join("zplab-path-grants") + .join("shared.db"); + // A sibling profile under another spelling of the same live file. + assert!(still_referenced( + &[respelled.to_string_lossy().into_owned()], + &file.to_string_lossy() + )); + // A dead path kept only by literal equality. + assert!(still_referenced( + &["C:/gone/old.db".into()], + "C:/gone/old.db" + )); + assert!(!still_referenced( + &[scratch("unrelated.db").to_string_lossy().into_owned()], + &file.to_string_lossy() + )); + } + + #[test] + fn missing_or_corrupt_store_yields_no_grants() { + assert!(read_grants(Path::new("C:/missing/store.json")).is_empty()); + let bad = scratch("bad.json"); + assert!(read_grants(&bad).is_empty()); + } +} diff --git a/src/components/PrinterSettings/DataSourcesTab.tsx b/src/components/PrinterSettings/DataSourcesTab.tsx index b3eda112..66dadd26 100644 --- a/src/components/PrinterSettings/DataSourcesTab.tsx +++ b/src/components/PrinterSettings/DataSourcesTab.tsx @@ -8,9 +8,10 @@ import { dbPasswordCred, dbSetPassword, enqueueCredWrite, + pickSqliteFile, + revokeSqlitePath, } from '../../lib/db'; import { deleteCredential } from '../../lib/credentialStore'; -import { pickFilePath, SQLITE_FILTER } from '../../lib/fileDialogs'; import { formatTemplate } from '../../lib/formatTemplate'; import { ConfirmDialog } from '../ui/ConfirmDialog'; import { Select } from '../ui/Select'; @@ -49,8 +50,9 @@ export function DataSourcesTab() { // Local mirror of the password input; committed to the keychain on blur, // never into the store. Empty means "keep whatever is stored". const [passwordDraft, setPasswordDraft] = useState(''); - // Bumped when a new password lands so a previously-failed table fetch retries. - const [credNonce, setCredNonce] = useState(0); + // Bumped when a new password or path grant lands so a previously-failed + // table fetch retries even when the profile content is unchanged. + const [retryNonce, setRetryNonce] = useState(0); const connectionReady = profile !== null && @@ -61,7 +63,7 @@ export function DataSourcesTab() { // Keyed by full connection identity so edits refetch but a rename doesn't; // a key mismatch is a stale result, reset derived in render (not via effect). const tablesKey = profile - ? `${credNonce} ${JSON.stringify({ ...profile, name: undefined })}` + ? `${retryNonce} ${JSON.stringify({ ...profile, name: undefined })}` : ''; const [tablesResult, setTablesResult] = useState<{ key: string; @@ -113,6 +115,15 @@ export function DataSourcesTab() { setSelectedTable(''); }; + // Call AFTER the profile mutation so the keep list reflects the new state + // (revokeSqlitePath documents the shared-grant semantics). + const revokeOrphanedGrant = (path: string) => { + const keep = useLabelStore + .getState() + .dbProfiles.flatMap((p) => (p.driver === 'sqlite' && p.path ? [p.path] : [])); + void revokeSqlitePath(path, keep); + }; + const handleDriverChange = (driver: Driver) => { if (!profile || driver === profile.driver) return; // Leaving the network drivers orphans the keychain password; drop it so a @@ -120,22 +131,42 @@ export function DataSourcesTab() { if (driver === 'sqlite' && profile.driver !== 'sqlite') { void deleteStoredPassword(profile.id); } + const orphanedPath = profile.driver === 'sqlite' ? profile.path : ''; const base = { id: profile.id, name: profile.name }; updateDbProfile( driver === 'sqlite' ? { ...base, driver, path: '' } : { ...base, driver, host: '', database: '', user: '' }, ); + // Leaving sqlite orphans the path grant; drop it like the password above. + if (orphanedPath) revokeOrphanedGrant(orphanedPath); setSelectedTable(''); setPasswordDraft(''); }; const handleBrowse = () => { - void pickFilePath(SQLITE_FILTER).then((path) => { - if (path && profile?.driver === 'sqlite') updateDbProfile({ ...profile, path }); - }); + if (!profile || profile.driver !== 'sqlite') return; + const previous = profile.path; + void pickSqliteFile(previous || undefined).then((path) => { + if (path && profile.driver === 'sqlite') { + updateDbProfile({ ...profile, path }); + // Re-picking the SAME file (grant recovery) leaves the profile content + // unchanged, so tablesKey alone would not refetch; bump explicitly. + setRetryNonce((n) => n + 1); + // After the update, so the keep list already contains the new path + // (Rust keeps the grant when both resolve to the same file). + if (previous && previous !== path) revokeOrphanedGrant(previous); + } + }, browseError); }; + // Pick succeeded but the file vanished before it could be granted; surface + // it instead of an unhandled rejection (path stays unchanged). + const browseError = (e: unknown) => + useLabelStore + .getState() + .setUserError(formatTemplate(tv.dbFetchErrorFmt, { error: String(e) })); + // Keychain writes serialize through the module-level queue in lib/db (shared // with the reconnect chip). A failed save leaves the keychain unchanged, so a // later Load just authenticates with the old password and surfaces the error. @@ -153,7 +184,7 @@ export function DataSourcesTab() { void enqueueCredWrite(() => dbSetPassword(netProfile, value).then(() => { setPasswordDraft(''); - setCredNonce((n) => n + 1); + setRetryNonce((n) => n + 1); }, credError), // draft stays so the user can retry ); }; @@ -163,7 +194,7 @@ export function DataSourcesTab() { const id = profile.id; setPasswordDraft(''); void enqueueCredWrite(() => - deleteCredential(dbPasswordCred(id)).then(() => setCredNonce((n) => n + 1), credError), + deleteCredential(dbPasswordCred(id)).then(() => setRetryNonce((n) => n + 1), credError), ); }; @@ -401,7 +432,11 @@ export function DataSourcesTab() { cancelLabel={tv.cancel} destructive onConfirm={() => { + const doomed = dbProfiles.find((p) => p.id === pendingDelete.id); removeDbProfile(pendingDelete.id); + // After the removal, so the keep list no longer contains the + // deleted profile; a sibling on the same file keeps the grant. + if (doomed?.driver === 'sqlite' && doomed.path) revokeOrphanedGrant(doomed.path); // Drop the orphaned keychain password with the profile. void deleteStoredPassword(pendingDelete.id); setPendingDelete(null); diff --git a/src/hooks/useExcelImportActions.ts b/src/hooks/useExcelImportActions.ts index 221ed8b2..d9d48ecc 100644 --- a/src/hooks/useExcelImportActions.ts +++ b/src/hooks/useExcelImportActions.ts @@ -1,8 +1,8 @@ import { useState } from 'react'; import { useLabelStore } from '../store/labelStore'; import { loadFetchedDataset, currentDataContext, isCurrentDataContext } from '../store/datasetActions'; -import { excelFetchDataset, excelListSheets } from '../lib/excel'; -import { basename, pickFilePath, EXCEL_FILTER } from '../lib/fileDialogs'; +import { excelFetchDataset, excelListSheets, pickExcelFile } from '../lib/excel'; +import { basename } from '../lib/fileDialogs'; import { formatTemplate } from '../lib/formatTemplate'; import { useT } from './useT'; @@ -32,7 +32,7 @@ export function useExcelImportActions() { // import, not silently inherit the new context's token. const token = currentDataContext(); try { - const path = await pickFilePath(EXCEL_FILTER); + const path = await pickExcelFile(); if (!path) return; const sheets = await excelListSheets(path); // A document loaded during the pick/list: don't open a dialog that is diff --git a/src/lib/db.ts b/src/lib/db.ts index 4da34bfa..f0aa755a 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -66,6 +66,22 @@ const specOf = (profile: DbProfile): DbSpec => sslMode: profile.sslMode ?? 'prefer', }; +/** Native pick that also grants the path Rust-side (persisted): the sqlite + * db_* commands refuse paths that did not come from this dialog. Returns the + * canonical path (store it verbatim), null on cancel; `suggest` preseeds. */ +export async function pickSqliteFile(suggest?: string): Promise { + const { invoke } = await import('@tauri-apps/api/core'); + return invoke('pick_sqlite_file', { suggest: suggest ?? null }); +} + +/** Drop a persisted sqlite path grant so the set doesn't ratchet up forever. + * `keep` = every path still referenced by a profile; Rust compares them + * canonically, so a sibling under another spelling keeps its grant. */ +export async function revokeSqlitePath(path: string, keep: string[]): Promise { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('revoke_db_path', { path, keep }); +} + /** Keychain account for a profile's password (mirrors Rust `password_cred`). */ export const dbPasswordCred = (profileId: string): string => `db-profile-${profileId}`; diff --git a/src/lib/excel.ts b/src/lib/excel.ts index 9cbad5ba..e7e51698 100644 --- a/src/lib/excel.ts +++ b/src/lib/excel.ts @@ -1,6 +1,13 @@ import type { DatasetInput } from '@zplab/core/types/DataSource'; import type { DbRows } from './db'; +/** Native pick that also grants the path Rust-side (session): excel_* commands + * refuse paths that did not come from this dialog. Null on cancel. */ +export async function pickExcelFile(): Promise { + const { invoke } = await import('@tauri-apps/api/core'); + return invoke('pick_excel_file'); +} + export async function excelListSheets(path: string): Promise { const { invoke } = await import('@tauri-apps/api/core'); return invoke('excel_list_sheets', { path }); diff --git a/src/lib/fileDialogs.ts b/src/lib/fileDialogs.ts index 6be23b68..151c602e 100644 --- a/src/lib/fileDialogs.ts +++ b/src/lib/fileDialogs.ts @@ -15,8 +15,6 @@ export interface FileFilter { export const DESIGN_FILTER: FileFilter = { name: 'JSON', extensions: ['json'] }; export const CSV_FILTER: FileFilter = { name: 'CSV', extensions: ['csv'] }; export const ZPL_FILTER: FileFilter = { name: 'ZPL', extensions: ['zpl'] }; -export const SQLITE_FILTER: FileFilter = { name: 'SQLite', extensions: ['sqlite', 'sqlite3', 'db', 'db3'] }; -export const EXCEL_FILTER: FileFilter = { name: 'Excel', extensions: ['xlsx', 'xlsm', 'xls', 'ods'] }; /** Shown when a save/export write fails; domain-neutral so both the design save * and the ZPL export surface the same message. */ @@ -59,13 +57,6 @@ async function pickFile( return { name: basename(path), value: await read(path) }; } -/** Pick a file and return only its path (desktop-only; consumers that hand - * the path to a Rust command instead of reading the bytes themselves). */ -export async function pickFilePath(filter: FileFilter): Promise { - const { open } = await import('@tauri-apps/plugin-dialog'); - return open({ multiple: false, directory: false, filters: [filter] }); -} - export async function pickFileText(filter: FileFilter): Promise<{ name: string; text: string } | null> { const picked = await pickFile(filter, async (path) => { const { readTextFile } = await import('@tauri-apps/plugin-fs');