diff --git a/Cargo.toml b/Cargo.toml index 28a111d..41a19ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 167 +# version: 168 [workspace] resolver = "3" members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"] [workspace.package] -version = "0.2.6-pre.3.fix.4" +version = "0.2.6-pre.4" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/ROADMAP.md b/ROADMAP.md index 942fc41..61f26c9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,5 @@ - + # Roadmap KSP @@ -50,7 +50,7 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U - [X] `0.2.3` — HTTP Transactions stable : 11/11 wrappers typés publiés, classification `8 Read / 2 WriteSubmission / 1 Simulation`, no-resend ambigu prouvé pour les write submissions, `KSP-TRANSPORT-007` réaudité conforme sur les 37 wrappers HTTP courants, graphes Cargo et deux smokes Devnet validés ; `0.2.4` reprend les 15 Blocks/Economics restants. - [X] `0.2.4` — HTTP Blocks + Economics stable : 15/15 wrappers `V0_2_4` publiés, surface typed complète à 52/52 méthodes courantes, 14/14 historiques conservées, réaudit SIMD/inventaire final et `KSP-TRANSPORT-007` global validés ; deux smokes Devnet passés avant publication. - [X] `0.2.5` — Wallet foundation stable : `.kspwallet` V1, VIEW/OWNER indépendants, Argon2id/XChaCha20-Poly1305, autorité Ed25519 OWNER, persistence no-clobber, signature, administration/rotations/révocation VIEW forte, import/export Solana CLI JSON + Base58, canaris adversariaux, interop externe et documentation durable publiés. La clôture `pre.010-fix.001`–`fix.003` ajoute `ed25519-dalek 3.0.0` direct, normalise le Rust workspace et installe l’audit structurel Python complémentaire à rustfmt/Clippy. `Pubkey` reste via `ksp-core-lib`, la keypair reste encapsulée dans Wallet et Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet. -- [ ] `0.2.6` — Introduire `ksp-app-wallet-desk` utilisant Config composite + Wallet + transport HTTP. `pre.001` fixe le sizing et le gabarit ; `pre.002` matérialise la crate Tauri et son shell splash/main avec Config + Logging bootstrap, ports `1432/1433`, Bootstrap, Font Awesome, DataTables/Select, SimpleBar, resize-observer-polyfill, TS-RS et bridge frontend Logging. `pre.003` matérialise `cfg.std.wallet`/`schema.std.wallet`, le composite `cfg.composite.ksp-app-wallet-desk`, `ResolvedWalletConfig`, `wallets_directory` global + `wallets_subdirectory` par profil et la préparation automatique des répertoires côté application avec logs debug/error. `pre.003-fix.001` corrige les premiers canaris Config et active la trace des interactions frontend sans valeur de contrôle. `pre.003-fix.002` corrige le scanner `.env.example` sur les fragments d’identifiants Rust, généralise `std.logging` avec les profils console-only, `file_info`, `superdev` et `supertrace`, et sélectionne temporairement `supertrace` dans le composite Wallet Desk. `pre.003-fix.003` corrige les canaris de ce changement : closures explicites compatibles avec `clippy::implicit_return` et distinction entre fragment KSP incorporé dans un identifiant et nom d’environnement concret suffixé. `pre.003-fix.004` corrige les cinq closures restantes du canari de composition Wallet Desk afin que `cargo clippy --workspace --all-targets` respecte intégralement `clippy::implicit_return`, sans modifier Config ni Logging. Les tranches suivantes ajoutent inventory, lifecycle VIEW/OWNER, secrets `KSP_SECRET_WALLET_PASS_*`, identité autorisée + `getBalance`, puis administration/import/export, avec une dernière tranche prévue pour README/USAGE/docs/validation, prompt `0.2.7` et build Tauri final. +- [ ] `0.2.6` — Introduire `ksp-app-wallet-desk` utilisant Config composite + Wallet + transport HTTP. `pre.001` fixe le sizing et le gabarit ; `pre.002` matérialise la crate Tauri et son shell splash/main avec Config + Logging bootstrap, ports `1432/1433`, Bootstrap, Font Awesome, DataTables/Select, SimpleBar, resize-observer-polyfill, TS-RS et bridge frontend Logging. `pre.003` matérialise `cfg.std.wallet`/`schema.std.wallet`, le composite `cfg.composite.ksp-app-wallet-desk`, `ResolvedWalletConfig`, `wallets_directory` global + `wallets_subdirectory` par profil et la préparation automatique des répertoires côté application avec logs debug/error. `pre.003-fix.001` corrige les premiers canaris Config et active la trace des interactions frontend sans valeur de contrôle. `pre.003-fix.002` corrige le scanner `.env.example` sur les fragments d’identifiants Rust, généralise `std.logging` avec les profils console-only, `file_info`, `superdev` et `supertrace`, et sélectionne temporairement `supertrace` dans le composite Wallet Desk. `pre.003-fix.003` corrige les canaris de ce changement : closures explicites compatibles avec `clippy::implicit_return` et distinction entre fragment KSP incorporé dans un identifiant et nom d’environnement concret suffixé. `pre.003-fix.004` corrige les cinq closures restantes du canari de composition Wallet Desk afin que `cargo clippy --workspace --all-targets` respecte intégralement `clippy::implicit_return`, sans modifier Config ni Logging. `pre.004` branche ensuite l’inventory réel `.kspwallet` sur le répertoire effectif Config : enumeration non récursive, extension exacte, fichiers réguliers uniquement, rejet des symlinks, inspection locked via `ksp-wallet-lib`, diagnostics sûrs par ligne, DataTable réel, refresh et sélection root-scoped revalidée côté Rust sans Pubkey/alias/notes. Les tranches suivantes ajoutent création/lifecycle VIEW/OWNER, secrets `KSP_SECRET_WALLET_PASS_*`, identité autorisée + `getBalance`, puis administration/import/export, avec une dernière tranche prévue pour README/USAGE/docs/validation, prompt `0.2.7` et build Tauri final. - [ ] `0.2.7` — Étendre `ksp-onchain-transport-lib` au WebSocket Solana standard complet ; permettre plusieurs sessions sur une même URL sans imposer encore un pool automatique complexe. - [ ] `0.2.8` — Ajouter Helius LaserStream WebSocket comme extension du moteur WebSocket standard, sans duplication de client. - [ ] `0.2.9` — Ajouter une première fondation Yellowstone gRPC standard/provider-neutral ; dimensionner la surface exacte à `pre.001` selon la documentation normative actuelle. diff --git a/crates/ksp-app-wallet-desk/Cargo.toml b/crates/ksp-app-wallet-desk/Cargo.toml index 3cc1989..b5d7c78 100644 --- a/crates/ksp-app-wallet-desk/Cargo.toml +++ b/crates/ksp-app-wallet-desk/Cargo.toml @@ -1,5 +1,5 @@ # file: crates/ksp-app-wallet-desk/Cargo.toml -# version: 1 +# version: 2 [package] name = "ksp-app-wallet-desk" @@ -27,10 +27,11 @@ fs2.workspace = true ksp-config-lib = { path = "../ksp-config-lib" } ksp-core-lib = { path = "../ksp-core-lib" } ksp-logging-lib = { path = "../ksp-logging-lib" } +ksp-wallet-lib = { path = "../ksp-wallet-lib" } serde = { workspace = true, features = ["derive"] } tauri.workspace = true tauri-plugin-tracing.workspace = true -tokio = { workspace = true, features = ["time"] } +tokio = { workspace = true, features = ["fs", "rt", "time"] } ts-rs.workspace = true [dev-dependencies] diff --git a/crates/ksp-app-wallet-desk/frontend/main.html b/crates/ksp-app-wallet-desk/frontend/main.html index 7095df4..16d472a 100644 --- a/crates/ksp-app-wallet-desk/frontend/main.html +++ b/crates/ksp-app-wallet-desk/frontend/main.html @@ -37,9 +37,9 @@

Dashboard

-

Composition Config Wallet Desk active — inventory Wallet branché en pre.004.

+

Inventaire `.kspwallet` root-scoped et inspection locked actifs.

- 0.2.6-pre.003 + 0.2.6-pre.004
@@ -66,9 +66,14 @@
Wallet courant
-
- -

Aucun wallet sélectionné.

+
+ +

Aucun wallet sélectionné.

+
+
État
+
Format
+
VIEW
+
@@ -81,14 +86,14 @@ Wallets disponibles verrouillé · ouvert
-
- +
ÉtatFilenameFormatVIEWInspection
@@ -102,7 +107,7 @@ diff --git a/crates/ksp-app-wallet-desk/frontend/ts/main.ts b/crates/ksp-app-wallet-desk/frontend/ts/main.ts index d5907df..03aaeb5 100644 --- a/crates/ksp-app-wallet-desk/frontend/ts/main.ts +++ b/crates/ksp-app-wallet-desk/frontend/ts/main.ts @@ -1,5 +1,5 @@ // file: crates/ksp-app-wallet-desk/frontend/ts/main.ts -// version: 3 +// version: 4 import "bootstrap"; import DataTable from "datatables.net-bs5"; @@ -8,7 +8,10 @@ import ResizeObserver from "resize-observer-polyfill"; import "simplebar"; import { getCurrentWindow } from "@tauri-apps/api/window"; import type { RuntimeStatusDto } from "./bindings/ksp_app_wallet_desk/dto_common/RuntimeStatusDto.ts"; -import { frontendDebug, frontendInfo, frontendTrace, installFrontendConsoleBridge } from "./frontend_log"; +import type { LockedWalletDto } from "./bindings/ksp_app_wallet_desk/wallet_inventory/LockedWalletDto.ts"; +import type { WalletInventoryEntryDto } from "./bindings/ksp_app_wallet_desk/wallet_inventory/WalletInventoryEntryDto.ts"; +import type { WalletSelectionRequestDto } from "./bindings/ksp_app_wallet_desk/wallet_inventory/WalletSelectionRequestDto.ts"; +import { frontendDebug, frontendInfo, frontendTrace, frontendWarn, installFrontendConsoleBridge } from "./frontend_log"; import { invokeKsp } from "./invoke"; import "../sass/main.scss"; @@ -95,20 +98,169 @@ function bindNavigation(): void { frontendTrace("main", "Wallet Desk navigation handlers installed"); } +function appendIconText(cell: HTMLTableCellElement, iconClass: string, text: string): void { + const icon = document.createElement("i"); + icon.className = `fa-solid ${iconClass} me-2`; + icon.setAttribute("aria-hidden", "true"); + const label = document.createElement("span"); + label.textContent = text; + cell.append(icon, label); +} + +function renderWalletInventoryRow(entry: WalletInventoryEntryDto): HTMLTableRowElement { + const row = document.createElement("tr"); + row.dataset.walletId = entry.walletId; + row.dataset.walletSelectable = entry.inspectionStatus === "valid" ? "true" : "false"; + row.dataset.inspectionStatus = entry.inspectionStatus; + const state = document.createElement("td"); + if (entry.state === "locked") { + appendIconText(state, "fa-lock", "Locked"); + } else { + appendIconText(state, "fa-triangle-exclamation", "Erreur"); + } + const filename = document.createElement("td"); + filename.textContent = entry.filename; + const format = document.createElement("td"); + format.textContent = entry.formatVersion === null ? "—" : entry.formatVersion.toString(); + const view = document.createElement("td"); + view.textContent = entry.viewEnabled === null ? "—" : entry.viewEnabled ? "enabled" : "disabled"; + const inspection = document.createElement("td"); + if (entry.inspectionStatus === "valid") { + inspection.textContent = "Valide"; + } else if (entry.diagnostic) { + inspection.textContent = `Invalide — ${entry.diagnostic.domain}.${entry.diagnostic.code}`; + inspection.title = entry.diagnostic.message; + } else { + inspection.textContent = "Invalide"; + } + row.append(state, filename, format, view, inspection); + return row; +} + function initializeWalletTable(): void { + if (DataTable.isDataTable("#walletInventoryTable")) { + new DataTable("#walletInventoryTable").destroy(); + } new DataTable("#walletInventoryTable", { order: [[1, "asc"]], pageLength: 10, select: { + selector: "tbody tr[data-wallet-selectable='true'] td", style: "single", }, language: { - emptyTable: "L'inventaire Wallet sera branché en pre.004.", + emptyTable: "Aucun wallet .kspwallet disponible.", search: "Filtrer :", zeroRecords: "Aucun wallet correspondant.", }, }); - frontendDebug("main", "Wallet inventory DataTable initialized", { phase: "pre.003-config-wallet" }); + frontendDebug("main", "Wallet inventory DataTable initialized", { phase: "pre.004-wallet-inventory" }); +} + +function renderWalletInventory(entries: WalletInventoryEntryDto[]): void { + if (DataTable.isDataTable("#walletInventoryTable")) { + new DataTable("#walletInventoryTable").destroy(); + } + const body = document.querySelector("#walletInventoryBody"); + if (body) { + body.replaceChildren(...entries.map(entry => renderWalletInventoryRow(entry))); + } + initializeWalletTable(); + const invalidCount = entries.filter(entry => entry.inspectionStatus === "invalid").length; + frontendDebug("main", "Wallet inventory rendered", { entryCount: entries.length, invalidCount }); +} + +function clearSelectedWallet(): void { + const icon = document.querySelector("#currentWalletIcon"); + const filename = document.querySelector("#currentWalletFilename"); + const state = document.querySelector("#currentWalletState"); + const format = document.querySelector("#currentWalletFormat"); + const view = document.querySelector("#currentWalletView"); + if (icon) { + icon.className = "fa-solid fa-lock fa-2x mb-3 text-body-secondary"; + } + if (filename) { + filename.textContent = "Aucun wallet sélectionné."; + } + if (state) { + state.textContent = "—"; + } + if (format) { + format.textContent = "—"; + } + if (view) { + view.textContent = "—"; + } +} + +function renderSelectedWallet(wallet: LockedWalletDto): void { + const icon = document.querySelector("#currentWalletIcon"); + const filename = document.querySelector("#currentWalletFilename"); + const state = document.querySelector("#currentWalletState"); + const format = document.querySelector("#currentWalletFormat"); + const view = document.querySelector("#currentWalletView"); + if (icon) { + icon.className = "fa-solid fa-lock fa-2x mb-3"; + } + if (filename) { + filename.textContent = wallet.filename; + } + if (state) { + state.textContent = "Locked"; + } + if (format) { + format.textContent = wallet.formatVersion.toString(); + } + if (view) { + view.textContent = wallet.viewEnabled ? "enabled" : "disabled"; + } + frontendDebug("main", "Locked Wallet selection rendered", { walletId: wallet.walletId, formatVersion: wallet.formatVersion, viewEnabled: wallet.viewEnabled }); +} + +async function selectWallet(walletId: string): Promise { + const request: WalletSelectionRequestDto = { walletId }; + try { + const wallet = await invokeKsp("main", "select_wallet", { request }); + renderSelectedWallet(wallet); + } catch { + clearSelectedWallet(); + frontendWarn("main", "Locked Wallet selection failed", { walletId }); + } +} + +function bindWalletTableSelection(): void { + const table = document.querySelector("#walletInventoryTable"); + if (!table) { + return; + } + table.addEventListener("click", event => { + const source = event.target; + if (!(source instanceof Element)) { + return; + } + const row = source.closest("tbody tr[data-wallet-id]"); + if (!row) { + return; + } + const walletId = row.dataset.walletId; + if (!walletId) { + return; + } + if (row.dataset.walletSelectable !== "true") { + frontendDebug("main", "Invalid Wallet inventory row selection ignored", { walletId }); + return; + } + frontendTrace("main", "Wallet inventory row selected", { walletId }); + void selectWallet(walletId); + }); + frontendTrace("main", "Wallet inventory row selection handler installed"); +} + +async function loadWalletInventory(command: "list_wallets" | "refresh_wallets"): Promise { + frontendDebug("main", "Wallet inventory load requested", { command }); + const entries = await invokeKsp("main", command); + clearSelectedWallet(); + renderWalletInventory(entries); } function renderRuntimeStatus(status: RuntimeStatusDto): void { @@ -158,7 +310,7 @@ function renderRuntimeStatus(status: RuntimeStatusDto): void { phase.textContent = status.shellPhase; } if (shellStatus) { - shellStatus.textContent = "Config Wallet Desk résolue ; répertoire Wallet prêt."; + shellStatus.textContent = "Config résolue ; inventaire Wallet prêt."; } frontendTrace("main", "Wallet Desk runtime status rendered", { compositeProfile: status.activeCompositeProfile, @@ -177,7 +329,13 @@ async function loadRuntimeStatus(): Promise { function bindShellActions(): void { document.querySelectorAll("[data-shell-action]").forEach(button => { button.addEventListener("click", () => { - frontendDebug("main", "Wallet Desk shell action clicked", { action: button.dataset.shellAction ?? "unknown", enabled: !button.disabled }); + const action = button.dataset.shellAction ?? "unknown"; + frontendDebug("main", "Wallet Desk shell action clicked", { action, enabled: !button.disabled }); + if (action === "refresh-wallets" && !button.disabled) { + void loadWalletInventory("refresh_wallets").catch(() => { + frontendWarn("main", "Wallet inventory refresh failed"); + }); + } }); }); frontendTrace("main", "Wallet Desk shell action handlers installed"); @@ -188,17 +346,19 @@ async function initializeMain(): Promise { frontendInfo("main", "Wallet Desk main frontend loaded", { windowLabel }); bindFrontendInteractions(); bindNavigation(); + bindWalletTableSelection(); bindShellActions(); - initializeWalletTable(); activateView("dashboard", "startup"); try { await loadRuntimeStatus(); + await loadWalletInventory("list_wallets"); } catch { + renderWalletInventory([]); const shellStatus = document.querySelector("#shellStatus"); if (shellStatus) { - shellStatus.textContent = "Le statut runtime n'a pas pu être chargé."; + shellStatus.textContent = "Le statut runtime ou l'inventaire Wallet n'a pas pu être chargé."; } - frontendTrace("main", "Wallet Desk shell status replaced", { status: "runtime_error" }); + frontendWarn("main", "Wallet Desk startup data load failed"); } } diff --git a/crates/ksp-app-wallet-desk/package.json b/crates/ksp-app-wallet-desk/package.json index 3a5af7f..6d85f75 100644 --- a/crates/ksp-app-wallet-desk/package.json +++ b/crates/ksp-app-wallet-desk/package.json @@ -1,7 +1,7 @@ { "name": "ksp-app-wallet-desk", "private": true, - "version": "0.2.6-pre.3.fix.4", + "version": "0.2.6-pre.4", "type": "module", "scripts": { "dev": "vite", diff --git a/crates/ksp-app-wallet-desk/src/app_state.rs b/crates/ksp-app-wallet-desk/src/app_state.rs index efbcdc4..413beab 100644 --- a/crates/ksp-app-wallet-desk/src/app_state.rs +++ b/crates/ksp-app-wallet-desk/src/app_state.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-wallet-desk/src/app_state.rs -// version: 3 +// version: 4 //! Shared backend state owned by the Wallet Desk Tauri application. @@ -61,7 +61,7 @@ impl AppState { }); } - /// Builds the safe Config-composition status DTO exposed during pre.003. + /// Builds the safe runtime status DTO exposed by the Wallet Desk shell. pub(crate) fn runtime_status(&self) -> ksp_core_lib::Result { let document_count = self.config_management.engine().registry().descriptors().count(); let document_count = u32::try_from(document_count); @@ -100,13 +100,19 @@ impl AppState { effective_wallets_directory_created_on_startup: self.wallet_config_startup.effective_directory_created_on_startup(), fallback_logging_active: runtime.fallback_active, root_wallets_directory_created_on_startup: self.wallet_config_startup.root_directory_created_on_startup(), - shell_phase: "pre.003-config-wallet".to_owned(), + shell_phase: "pre.004-wallet-inventory".to_owned(), startup_diagnostic: runtime.startup_diagnostic.clone(), wallets_directory: resolved.wallets_directory().to_string_lossy().into_owned(), wallets_subdirectory, }); } + /// Returns the effective Config-managed Wallet directory used by inventory operations. + #[must_use] + pub(crate) fn wallet_inventory_root(&self) -> &std::path::Path { + return self.wallet_config_startup.resolved().effective_wallets_directory(); + } + /// Returns the resolved common splash timings captured during bootstrap. #[must_use] pub(crate) const fn splash_settings(&self) -> crate::SplashSettings { diff --git a/crates/ksp-app-wallet-desk/src/constants.rs b/crates/ksp-app-wallet-desk/src/constants.rs index 72dd658..04eb8d7 100644 --- a/crates/ksp-app-wallet-desk/src/constants.rs +++ b/crates/ksp-app-wallet-desk/src/constants.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-wallet-desk/src/constants.rs -// version: 2 +// version: 3 //! Logging targets, domains and composite component identifiers owned by Wallet Desk. @@ -17,6 +17,8 @@ pub(crate) const TRACING_DOMAIN_FRONTEND: &str = "frontend"; pub(crate) const TRACING_DOMAIN_SHELL: &str = "wallet.shell"; /// Structured domain used while preparing Wallet filesystem roots. pub(crate) const TRACING_DOMAIN_WALLET_CONFIG: &str = "wallet.config"; +/// Structured domain used while enumerating and inspecting locked Wallet files. +pub(crate) const TRACING_DOMAIN_WALLET_INVENTORY: &str = "wallet.inventory"; /// Structured domain used by Tauri window lifecycle operations. pub(crate) const TRACING_DOMAIN_WINDOWS: &str = "desktop.window"; /// Owning target for backend events emitted by Wallet Desk. @@ -27,3 +29,5 @@ pub(crate) const TRACING_TARGET_FRONTEND: &str = "ksp-app-wallet-desk.frontend"; pub(crate) const TRACING_TARGET_FRONTEND_MAIN: &str = "ksp-app-wallet-desk.frontend.main"; /// Owning target for splash-window frontend events. pub(crate) const TRACING_TARGET_FRONTEND_SPLASH: &str = "ksp-app-wallet-desk.frontend.splash"; +/// Native Wallet filename suffix accepted by the Wallet Desk inventory. +pub(crate) const WALLET_FILE_SUFFIX: &str = ".kspwallet"; diff --git a/crates/ksp-app-wallet-desk/src/dto_common.rs b/crates/ksp-app-wallet-desk/src/dto_common.rs index f3f01ca..2126e66 100644 --- a/crates/ksp-app-wallet-desk/src/dto_common.rs +++ b/crates/ksp-app-wallet-desk/src/dto_common.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-wallet-desk/src/dto_common.rs -// version: 2 +// version: 3 //! Common Tauri DTOs shared by Wallet Desk shell commands. @@ -53,7 +53,7 @@ pub(crate) struct RuntimeStatusDto { pub(crate) fallback_logging_active: bool, /// Whether bootstrap created the configured global Wallet root. pub(crate) root_wallets_directory_created_on_startup: bool, - /// Current implementation phase exposed for the Config composition tranche. + /// Current implementation phase exposed by the Wallet Desk shell. pub(crate) shell_phase: String, /// Safe startup diagnostic that caused fallback Logging, when applicable. pub(crate) startup_diagnostic: std::option::Option, diff --git a/crates/ksp-app-wallet-desk/src/errors.rs b/crates/ksp-app-wallet-desk/src/errors.rs index e031c79..74ea019 100644 --- a/crates/ksp-app-wallet-desk/src/errors.rs +++ b/crates/ksp-app-wallet-desk/src/errors.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-wallet-desk/src/errors.rs -// version: 2 +// version: 3 //! Application-local error codes for Wallet Desk composition and desktop runtime surfaces. @@ -31,3 +31,7 @@ pub(crate) const ERROR_CODE_WALLET_DIRECTORY_INVALID: ksp_core_lib::ErrorCode = /// Wallet Desk could not inspect or create the configured Wallet directory tree. pub(crate) const ERROR_CODE_WALLET_DIRECTORY_PREPARE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet_desk", "wallet_directory_prepare_failed"); +/// Wallet Desk cannot enumerate or inspect its effective Wallet inventory directory. +pub(crate) const ERROR_CODE_WALLET_INVENTORY_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet_desk", "wallet_inventory_failed"); +/// A requested Wallet inventory identifier is unsafe or does not resolve to an eligible regular `.kspwallet` file. +pub(crate) const ERROR_CODE_WALLET_SELECTION_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet_desk", "wallet_selection_invalid"); diff --git a/crates/ksp-app-wallet-desk/src/lib.rs b/crates/ksp-app-wallet-desk/src/lib.rs index 812dcae..70acc1a 100644 --- a/crates/ksp-app-wallet-desk/src/lib.rs +++ b/crates/ksp-app-wallet-desk/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-wallet-desk/src/lib.rs -// version: 2 +// version: 3 //! Tauri desktop application shell for KSP Wallet management and inspection. @@ -19,6 +19,7 @@ mod tauri; mod tw_main; mod tw_splash; mod wallet_config; +mod wallet_inventory; /// Runs the KSP wallet desktop application. pub use self::tauri::run; @@ -49,6 +50,8 @@ pub(crate) use self::constants::TRACING_DOMAIN_FRONTEND; pub(crate) use self::constants::TRACING_DOMAIN_SHELL; /// Structured domain used while preparing Wallet filesystem roots. pub(crate) use self::constants::TRACING_DOMAIN_WALLET_CONFIG; +/// Structured domain used while enumerating and inspecting locked Wallet files. +pub(crate) use self::constants::TRACING_DOMAIN_WALLET_INVENTORY; /// Structured domain used by Tauri window lifecycle operations. pub(crate) use self::constants::TRACING_DOMAIN_WINDOWS; /// Owning target for backend events emitted by Wallet Desk. @@ -59,6 +62,8 @@ pub(crate) use self::constants::TRACING_TARGET_FRONTEND; pub(crate) use self::constants::TRACING_TARGET_FRONTEND_MAIN; /// Owning target for splash-window frontend events. pub(crate) use self::constants::TRACING_TARGET_FRONTEND_SPLASH; +/// Native Wallet filename suffix accepted by inventory operations. +pub(crate) use self::constants::WALLET_FILE_SUFFIX; /// Safe command error projection exposed to Tauri commands. pub(crate) use self::dto_common::CommandErrorDto; /// Initial application/runtime snapshot exposed to the Wallet Desk shell. @@ -89,6 +94,10 @@ pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED; pub(crate) use self::errors::ERROR_CODE_WALLET_DIRECTORY_INVALID; /// Wallet Desk could not inspect or create the configured Wallet directory tree. pub(crate) use self::errors::ERROR_CODE_WALLET_DIRECTORY_PREPARE_FAILED; +/// Wallet inventory filesystem operation failed. +pub(crate) use self::errors::ERROR_CODE_WALLET_INVENTORY_FAILED; +/// Wallet selection identifier is invalid or no longer eligible. +pub(crate) use self::errors::ERROR_CODE_WALLET_SELECTION_INVALID; /// Log payload sent by Wallet Desk frontend scripts. pub(crate) use self::frontend_logging::FrontendLogPayloadDto; /// Emits one validated frontend event through the KSP Logging facade. @@ -111,3 +120,17 @@ pub(crate) use self::tw_splash::splash_frontend_ready_service; pub(crate) use self::wallet_config::WalletConfigStartup; /// Resolves the composite-selected Wallet Config and prepares its application-owned directory tree. pub(crate) use self::wallet_config::initialize_wallet_config; +/// Safe locked Wallet projection returned after selection. +pub(crate) use self::wallet_inventory::LockedWalletDto; +/// Locked inspection outcome exposed by Wallet inventory rows. +pub(crate) use self::wallet_inventory::WalletInspectionStatusDto; +/// Safe Wallet inventory row projection. +pub(crate) use self::wallet_inventory::WalletInventoryEntryDto; +/// Visual lock/error state exposed by Wallet inventory rows. +pub(crate) use self::wallet_inventory::WalletInventoryStateDto; +/// Request DTO used for one root-scoped Wallet selection. +pub(crate) use self::wallet_inventory::WalletSelectionRequestDto; +/// Enumerates native Wallet files under the effective Config-managed root. +pub(crate) use self::wallet_inventory::list_wallet_inventory; +/// Re-inspects one root-scoped Wallet selection. +pub(crate) use self::wallet_inventory::select_locked_wallet; diff --git a/crates/ksp-app-wallet-desk/src/tauri.rs b/crates/ksp-app-wallet-desk/src/tauri.rs index aeba8db..f73816c 100644 --- a/crates/ksp-app-wallet-desk/src/tauri.rs +++ b/crates/ksp-app-wallet-desk/src/tauri.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-wallet-desk/src/tauri.rs -// version: 1 +// version: 2 //! Tauri runtime assembly for the KSP wallet desktop application. @@ -37,7 +37,14 @@ fn configure_plugins(builder: tauri::Builder) -> tauri::Builder) -> tauri::Builder { - return builder.invoke_handler(tauri::generate_handler![emit_frontend_log, get_runtime_status, splash_frontend_ready]); + return builder.invoke_handler(tauri::generate_handler![ + emit_frontend_log, + get_runtime_status, + list_wallets, + refresh_wallets, + select_wallet, + splash_frontend_ready + ]); } fn configure_setup(builder: tauri::Builder) -> tauri::Builder { @@ -73,6 +80,41 @@ fn get_runtime_status(state: tauri::State<'_, crate::AppState>) -> std::result:: }; } +#[tauri::command] +async fn list_wallets(state: tauri::State<'_, crate::AppState>) -> std::result::Result, crate::CommandErrorDto> { + let root = state.wallet_inventory_root().to_path_buf(); + let result = crate::list_wallet_inventory(root.as_path()).await; + return match result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)), + }; +} + +#[tauri::command] +async fn refresh_wallets( + state: tauri::State<'_, crate::AppState>, +) -> std::result::Result, crate::CommandErrorDto> { + let root = state.wallet_inventory_root().to_path_buf(); + let result = crate::list_wallet_inventory(root.as_path()).await; + return match result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)), + }; +} + +#[tauri::command] +async fn select_wallet( + request: crate::WalletSelectionRequestDto, + state: tauri::State<'_, crate::AppState>, +) -> std::result::Result { + let root = state.wallet_inventory_root().to_path_buf(); + let result = crate::select_locked_wallet(root.as_path(), request).await; + return match result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)), + }; +} + #[tauri::command] async fn splash_frontend_ready( app: tauri::AppHandle, diff --git a/crates/ksp-app-wallet-desk/src/wallet_config.rs b/crates/ksp-app-wallet-desk/src/wallet_config.rs index 21ea130..17fa9c9 100644 --- a/crates/ksp-app-wallet-desk/src/wallet_config.rs +++ b/crates/ksp-app-wallet-desk/src/wallet_config.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-wallet-desk/src/wallet_config.rs -// version: 1 +// version: 2 //! Wallet Desk composition adapter for the standard Wallet Config and its application-owned directory preparation. @@ -105,8 +105,9 @@ fn prepare_wallet_directories(resolved: &ksp_config_lib::ResolvedWalletConfig) - } fn ensure_global_wallet_root(path: &std::path::Path) -> ksp_core_lib::Result { - let metadata = std::fs::metadata(path); + let metadata = std::fs::symlink_metadata(path); return match metadata { + std::result::Result::Ok(metadata) if metadata.file_type().is_symlink() => directory_invalid(path, "configured Wallet root cannot be a symbolic link"), std::result::Result::Ok(metadata) if metadata.is_dir() => std::result::Result::Ok(false), std::result::Result::Ok(_) => directory_invalid(path, "configured Wallet root exists but is not a directory"), std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => { diff --git a/crates/ksp-app-wallet-desk/src/wallet_inventory.rs b/crates/ksp-app-wallet-desk/src/wallet_inventory.rs new file mode 100644 index 0000000..a5057b3 --- /dev/null +++ b/crates/ksp-app-wallet-desk/src/wallet_inventory.rs @@ -0,0 +1,233 @@ +// file: crates/ksp-app-wallet-desk/src/wallet_inventory.rs +// version: 1 + +//! Root-scoped Wallet inventory and locked-file selection for Wallet Desk. + +use ts_rs::TS; // rust-rules: trait-import + +/// Visual lock state projected for one Wallet inventory row. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_inventory/WalletInventoryStateDto.ts")] +pub(crate) enum WalletInventoryStateDto { + /// Eligible Wallet remains locked. + Locked, + /// Candidate could not be inspected as a valid locked Wallet. + Error, +} + +/// Locked inspection outcome projected for one Wallet inventory row. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_inventory/WalletInspectionStatusDto.ts")] +pub(crate) enum WalletInspectionStatusDto { + /// Native Wallet inspection succeeded. + Valid, + /// Candidate inspection failed with a safe diagnostic. + Invalid, +} + +/// Safe inventory row exposed while Wallet contents remain locked. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_inventory/WalletInventoryEntryDto.ts")] +pub(crate) struct WalletInventoryEntryDto { + /// Safe inspection diagnostic when the candidate is invalid. + pub(crate) diagnostic: std::option::Option, + /// Native Wallet filename without its parent path. + pub(crate) filename: String, + /// Native Wallet format version when locked inspection succeeds. + pub(crate) format_version: std::option::Option, + /// Locked inspection outcome. + pub(crate) inspection_status: WalletInspectionStatusDto, + /// Current inventory lock/error state. + pub(crate) state: WalletInventoryStateDto, + /// Root-scoped identifier accepted by selection commands. + pub(crate) wallet_id: String, + /// Whether the locked Wallet advertises an enabled VIEW slot. + pub(crate) view_enabled: std::option::Option, +} + +/// Safe locked Wallet projection returned after an explicit row selection. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_inventory/LockedWalletDto.ts")] +pub(crate) struct LockedWalletDto { + /// Native Wallet filename without its parent path. + pub(crate) filename: String, + /// Native Wallet format version. + pub(crate) format_version: u32, + /// Root-scoped identifier accepted by later Wallet Desk operations. + pub(crate) wallet_id: String, + /// Whether the locked Wallet advertises an enabled VIEW slot. + pub(crate) view_enabled: bool, +} + +/// Request DTO for selecting one root-scoped Wallet inventory entry. +#[derive(serde::Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_inventory/WalletSelectionRequestDto.ts")] +pub(crate) struct WalletSelectionRequestDto { + /// Root-scoped Wallet identifier returned by the inventory. + pub(crate) wallet_id: String, +} + +/// Enumerates eligible native Wallet files and returns deterministic locked projections. +pub(crate) async fn list_wallet_inventory(root: &std::path::Path) -> ksp_core_lib::Result> { + let root_text = root.to_string_lossy().into_owned(); + ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_INVENTORY, root_path = root_text.as_str(), "Wallet inventory refresh started"); + let reader = tokio::fs::read_dir(root).await; + let mut reader = match reader { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(inventory_io_error(root, "effective Wallet directory cannot be enumerated", error)), + }; + let mut entries = std::vec::Vec::new(); + loop { + let next = reader.next_entry().await; + let entry = match next { + std::result::Result::Ok(std::option::Option::Some(value)) => value, + std::result::Result::Ok(std::option::Option::None) => break, + std::result::Result::Err(error) => return std::result::Result::Err(inventory_io_error(root, "Wallet directory entry cannot be read", error)), + }; + let filename = entry.file_name(); + let filename = match filename.to_str() { + std::option::Option::Some(value) if is_wallet_filename(value) => value.to_owned(), + std::option::Option::Some(_) => continue, + std::option::Option::None => { + ksp_logging_lib::warn!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_INVENTORY, "Wallet inventory skipped a non-UTF-8 filename"); + continue; + }, + }; + let path = entry.path(); + let metadata = tokio::fs::symlink_metadata(path.as_path()).await; + let metadata = match metadata { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + entries.push(invalid_inventory_entry(filename, inventory_io_error(path.as_path(), "Wallet candidate metadata cannot be inspected", error))); + continue; + }, + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + ksp_logging_lib::trace!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_INVENTORY, filename = filename.as_str(), is_symlink = metadata.file_type().is_symlink(), "Wallet inventory skipped a non-regular candidate"); + continue; + } + let inspected = ksp_wallet_lib::inspect_locked_wallet_file_v1(path.as_path()).await; + match inspected { + std::result::Result::Ok(locked) => entries.push(valid_inventory_entry(filename, locked)), + std::result::Result::Err(error) => { + ksp_logging_lib::warn!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_INVENTORY, filename = filename.as_str(), error_domain = error.code().domain(), error_code = error.code().code(), "Wallet inventory candidate failed locked inspection"); + entries.push(invalid_inventory_entry(filename, error)); + }, + } + } + entries.sort_by(|left, right| { + return left.filename.cmp(&right.filename); + }); + let invalid_count = entries + .iter() + .filter(|entry| { + return entry.inspection_status == WalletInspectionStatusDto::Invalid; + }) + .count(); + ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_INVENTORY, root_path = root_text.as_str(), entry_count = entries.len(), invalid_count, "Wallet inventory refresh completed"); + return std::result::Result::Ok(entries); +} + +/// Re-resolves and re-inspects one selected inventory identifier without exposing its full path. +pub(crate) async fn select_locked_wallet(root: &std::path::Path, request: WalletSelectionRequestDto) -> ksp_core_lib::Result { + let path = resolve_wallet_path(root, request.wallet_id.as_str()).await; + let path = match path { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let locked = ksp_wallet_lib::inspect_locked_wallet_file_v1(path.as_path()).await; + let locked = match locked { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_INVENTORY, wallet_id = request.wallet_id.as_str(), format_version = locked.format_version(), view_enabled = locked.view_enabled(), "Locked Wallet selected from inventory"); + return std::result::Result::Ok(LockedWalletDto { + filename: request.wallet_id.clone(), + format_version: locked.format_version(), + wallet_id: request.wallet_id, + view_enabled: locked.view_enabled(), + }); +} + +fn valid_inventory_entry(filename: String, locked: ksp_wallet_lib::LockedWalletInfo) -> WalletInventoryEntryDto { + return WalletInventoryEntryDto { + diagnostic: std::option::Option::None, + filename: filename.clone(), + format_version: std::option::Option::Some(locked.format_version()), + inspection_status: WalletInspectionStatusDto::Valid, + state: WalletInventoryStateDto::Locked, + wallet_id: filename, + view_enabled: std::option::Option::Some(locked.view_enabled()), + }; +} + +fn invalid_inventory_entry(filename: String, error: ksp_core_lib::Error) -> WalletInventoryEntryDto { + return WalletInventoryEntryDto { + diagnostic: std::option::Option::Some(crate::CommandErrorDto::from_error(&error)), + filename: filename.clone(), + format_version: std::option::Option::None, + inspection_status: WalletInspectionStatusDto::Invalid, + state: WalletInventoryStateDto::Error, + wallet_id: filename, + view_enabled: std::option::Option::None, + }; +} + +fn is_wallet_filename(filename: &str) -> bool { + let path = std::path::Path::new(filename); + let components = path.components().count(); + return components == 1 + && !filename.contains('/') + && !filename.contains('\\') + && filename.ends_with(crate::WALLET_FILE_SUFFIX) + && filename.len() > crate::WALLET_FILE_SUFFIX.len(); +} + +async fn resolve_wallet_path(root: &std::path::Path, wallet_id: &str) -> ksp_core_lib::Result { + if !is_wallet_filename(wallet_id) { + return selection_invalid(wallet_id, "Wallet identifier must be one UTF-8 filename ending in .kspwallet"); + } + let path = root.join(wallet_id); + let metadata = tokio::fs::symlink_metadata(path.as_path()).await; + let metadata = match metadata { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return selection_invalid(wallet_id, "Wallet selection no longer exists in the effective inventory directory"); + }, + std::result::Result::Err(error) => { + return std::result::Result::Err(inventory_io_error(path.as_path(), "Wallet selection metadata cannot be inspected", error)); + }, + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return selection_invalid(wallet_id, "Wallet selection must resolve to a regular non-symlink file"); + } + return std::result::Result::Ok(path); +} + +fn selection_invalid(wallet_id: &str, reason: &'static str) -> ksp_core_lib::Result { + ksp_logging_lib::warn!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_INVENTORY, wallet_id, reason, "Wallet inventory selection rejected"); + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_WALLET_SELECTION_INVALID, "Wallet inventory selection is invalid") + .with_context("wallet_id", wallet_id) + .with_context("reason", reason), + ); +} + +fn inventory_io_error(path: &std::path::Path, reason: &'static str, source: std::io::Error) -> ksp_core_lib::Error { + let path_text = path.to_string_lossy().into_owned(); + let source_kind = std::format!("{:?}", source.kind()); + ksp_logging_lib::error!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_INVENTORY, path = path_text.as_str(), reason, source_kind = source_kind.as_str(), "Wallet inventory filesystem operation failed"); + return ksp_core_lib::Error::new(crate::ERROR_CODE_WALLET_INVENTORY_FAILED, "Wallet inventory filesystem operation failed") + .with_context("path", path_text) + .with_context("reason", reason) + .with_source(source); +} + +#[cfg(test)] +#[path = "../unit_tests/wallet_inventory.rs"] +mod tests; diff --git a/crates/ksp-app-wallet-desk/tauri.conf.json b/crates/ksp-app-wallet-desk/tauri.conf.json index 5a19d42..805d39b 100644 --- a/crates/ksp-app-wallet-desk/tauri.conf.json +++ b/crates/ksp-app-wallet-desk/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "KSP Wallet Desk", - "version": "0.2.6-pre.3.fix.4", + "version": "0.2.6-pre.4", "identifier": "com.sasedev.ksp-app-wallet-desk", "build": { "beforeDevCommand": "npm run dev", diff --git a/crates/ksp-app-wallet-desk/tests/desktop_contract.rs b/crates/ksp-app-wallet-desk/tests/desktop_contract.rs index fbbdc7f..02b9915 100644 --- a/crates/ksp-app-wallet-desk/tests/desktop_contract.rs +++ b/crates/ksp-app-wallet-desk/tests/desktop_contract.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs -// version: 3 +// version: 4 //! Desktop build, shell and Config-status contract audits for Wallet Desk. @@ -86,6 +86,28 @@ fn shell_contains_wallet_navigation_datatable_and_lock_state_icons() { assert!(main.contains("simplebar")); } +#[test] +fn pre_004_inventory_uses_wallet_library_and_exposes_only_locked_safe_fields() { + let root = app_root(); + let manifest = read_text(root.join("Cargo.toml").as_path()); + let tauri = read_text(root.join("src/tauri.rs").as_path()); + let inventory = read_text(root.join("src/wallet_inventory.rs").as_path()); + let main = read_text(root.join("frontend/ts/main.ts").as_path()); + assert!(manifest.contains("ksp-wallet-lib = { path = \"../ksp-wallet-lib\" }")); + assert!(inventory.contains("ksp_wallet_lib::inspect_locked_wallet_file_v1")); + assert!(inventory.contains("symlink_metadata")); + assert!(inventory.contains("WALLET_FILE_SUFFIX")); + assert!(tauri.contains("list_wallets")); + assert!(tauri.contains("refresh_wallets")); + assert!(tauri.contains("select_wallet")); + assert!(main.contains("WalletInventoryEntryDto")); + assert!(main.contains("LockedWalletDto")); + assert!(main.contains("refresh_wallets")); + assert!(!inventory.contains("pubkey")); + assert!(!inventory.contains("alias")); + assert!(!inventory.contains("notes")); +} + #[test] fn frontend_control_interactions_are_trace_logged_without_control_values() { let root = app_root(); diff --git a/crates/ksp-app-wallet-desk/tests/desktop_security.rs b/crates/ksp-app-wallet-desk/tests/desktop_security.rs index bece6df..80cced4 100644 --- a/crates/ksp-app-wallet-desk/tests/desktop_security.rs +++ b/crates/ksp-app-wallet-desk/tests/desktop_security.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-wallet-desk/tests/desktop_security.rs -// version: 1 +// version: 2 //! Static desktop security contracts for the Wallet Desk pre.002 shell. @@ -36,3 +36,13 @@ fn frontend_shell_uses_no_browser_native_dialog_or_secret_storage() { assert!(!html.contains(forbidden), "forbidden frontend primitive {forbidden}"); } } + +#[test] +fn locked_inventory_frontend_never_receives_protected_wallet_identity_fields() { + let root = app_root(); + let inventory = read_text(root.join("src/wallet_inventory.rs").as_path()); + assert!(!inventory.contains("pubkey:")); + assert!(!inventory.contains("alias:")); + assert!(!inventory.contains("notes:")); + assert!(!inventory.contains("password:")); +} diff --git a/crates/ksp-app-wallet-desk/unit_tests/wallet_config.rs b/crates/ksp-app-wallet-desk/unit_tests/wallet_config.rs index a6e05a0..f404dca 100644 --- a/crates/ksp-app-wallet-desk/unit_tests/wallet_config.rs +++ b/crates/ksp-app-wallet-desk/unit_tests/wallet_config.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-wallet-desk/unit_tests/wallet_config.rs -// version: 1 +// version: 2 #[test] fn directory_preparation_creates_missing_root_and_nested_profile_path() { @@ -37,6 +37,30 @@ fn directory_preparation_rejects_existing_non_directory_root() { cleanup_fixture(root.as_path()); } +#[cfg(unix)] +#[test] +fn global_wallet_root_rejects_symbolic_link() { + let root = fixture_path("root-symlink"); + let outside = fixture_path("root-symlink-outside"); + cleanup_fixture(root.as_path()); + cleanup_fixture(outside.as_path()); + let outside_created = std::fs::create_dir_all(outside.as_path()); + assert!(outside_created.is_ok()); + if outside_created.is_ok() { + let linked = std::os::unix::fs::symlink(outside.as_path(), root.as_path()); + assert!(linked.is_ok()); + if linked.is_ok() { + let result = super::ensure_global_wallet_root(root.as_path()); + assert!(result.is_err()); + if let std::result::Result::Err(error) = result { + assert_eq!(error.code(), crate::ERROR_CODE_WALLET_DIRECTORY_INVALID); + } + } + } + cleanup_fixture(root.as_path()); + cleanup_fixture(outside.as_path()); +} + #[cfg(unix)] #[test] fn profile_directory_preparation_rejects_symbolic_link_components() { diff --git a/crates/ksp-app-wallet-desk/unit_tests/wallet_inventory.rs b/crates/ksp-app-wallet-desk/unit_tests/wallet_inventory.rs new file mode 100644 index 0000000..8bda4bc --- /dev/null +++ b/crates/ksp-app-wallet-desk/unit_tests/wallet_inventory.rs @@ -0,0 +1,124 @@ +// file: crates/ksp-app-wallet-desk/unit_tests/wallet_inventory.rs +// version: 1 + +const VALID_WALLET: &[u8] = include_bytes!("../../ksp-wallet-lib/tests/fixtures/kspwallet_v1_full_vector.json"); + +#[test] +fn inventory_lists_regular_wallets_reports_invalid_candidates_and_ignores_other_entries() { + let root = fixture_path("inventory"); + cleanup_fixture(root.as_path()); + let created = std::fs::create_dir_all(root.as_path()); + assert!(created.is_ok(), "inventory fixture root should be creatable: {created:?}"); + if created.is_ok() { + assert!(std::fs::write(root.join("beta.kspwallet"), VALID_WALLET).is_ok()); + assert!(std::fs::write(root.join("alpha.kspwallet"), b"not-json").is_ok()); + assert!(std::fs::write(root.join("ignored.txt"), VALID_WALLET).is_ok()); + assert!(std::fs::create_dir(root.join("directory.kspwallet")).is_ok()); + let runtime = tokio::runtime::Builder::new_current_thread().build(); + assert!(runtime.is_ok(), "test runtime should be constructible: {runtime:?}"); + if let std::result::Result::Ok(runtime) = runtime { + let inventory = runtime.block_on(crate::list_wallet_inventory(root.as_path())); + assert!(inventory.is_ok(), "inventory should complete: {inventory:?}"); + if let std::result::Result::Ok(entries) = inventory { + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].filename, "alpha.kspwallet"); + assert_eq!(entries[0].inspection_status, crate::WalletInspectionStatusDto::Invalid); + assert_eq!(entries[0].state, crate::WalletInventoryStateDto::Error); + assert!(entries[0].diagnostic.is_some()); + assert_eq!(entries[1].filename, "beta.kspwallet"); + assert_eq!(entries[1].inspection_status, crate::WalletInspectionStatusDto::Valid); + assert_eq!(entries[1].state, crate::WalletInventoryStateDto::Locked); + assert_eq!(entries[1].format_version, std::option::Option::Some(1)); + assert!(entries[1].view_enabled.is_some()); + assert!(entries[1].diagnostic.is_none()); + } + } + } + cleanup_fixture(root.as_path()); +} + +#[test] +fn selection_rejects_traversal_and_reinspects_one_valid_locked_wallet() { + let root = fixture_path("selection"); + cleanup_fixture(root.as_path()); + let created = std::fs::create_dir_all(root.as_path()); + assert!(created.is_ok(), "selection fixture root should be creatable: {created:?}"); + if created.is_ok() { + assert!(std::fs::write(root.join("selected.kspwallet"), VALID_WALLET).is_ok()); + let runtime = tokio::runtime::Builder::new_current_thread().build(); + assert!(runtime.is_ok(), "test runtime should be constructible: {runtime:?}"); + if let std::result::Result::Ok(runtime) = runtime { + let rejected = runtime + .block_on(crate::select_locked_wallet(root.as_path(), crate::WalletSelectionRequestDto { wallet_id: "../selected.kspwallet".to_owned() })); + assert!(rejected.is_err()); + if let std::result::Result::Err(error) = rejected { + assert_eq!(error.code(), crate::ERROR_CODE_WALLET_SELECTION_INVALID); + } + let rejected_backslash = runtime + .block_on(crate::select_locked_wallet(root.as_path(), crate::WalletSelectionRequestDto { wallet_id: "..\\selected.kspwallet".to_owned() })); + assert!(rejected_backslash.is_err()); + if let std::result::Result::Err(error) = rejected_backslash { + assert_eq!(error.code(), crate::ERROR_CODE_WALLET_SELECTION_INVALID); + } + let selected = + runtime.block_on(crate::select_locked_wallet(root.as_path(), crate::WalletSelectionRequestDto { wallet_id: "selected.kspwallet".to_owned() })); + assert!(selected.is_ok(), "valid locked Wallet should be selectable: {selected:?}"); + if let std::result::Result::Ok(selected) = selected { + assert_eq!(selected.wallet_id, "selected.kspwallet"); + assert_eq!(selected.filename, "selected.kspwallet"); + assert_eq!(selected.format_version, 1); + } + } + } + cleanup_fixture(root.as_path()); +} + +#[cfg(unix)] +#[test] +fn inventory_and_selection_exclude_symbolic_link_wallet_entries() { + let root = fixture_path("symlink-inventory"); + let outside = fixture_path("symlink-wallet"); + cleanup_fixture(root.as_path()); + cleanup_fixture(outside.as_path()); + let root_created = std::fs::create_dir_all(root.as_path()); + let outside_written = std::fs::write(outside.as_path(), VALID_WALLET); + assert!(root_created.is_ok()); + assert!(outside_written.is_ok()); + if root_created.is_ok() && outside_written.is_ok() { + let linked = std::os::unix::fs::symlink(outside.as_path(), root.join("linked.kspwallet")); + assert!(linked.is_ok()); + if linked.is_ok() { + let runtime = tokio::runtime::Builder::new_current_thread().build(); + assert!(runtime.is_ok()); + if let std::result::Result::Ok(runtime) = runtime { + let inventory = runtime.block_on(crate::list_wallet_inventory(root.as_path())); + assert_eq!(inventory.as_ref().ok().map(std::vec::Vec::len), std::option::Option::Some(0)); + let selected = runtime + .block_on(crate::select_locked_wallet(root.as_path(), crate::WalletSelectionRequestDto { wallet_id: "linked.kspwallet".to_owned() })); + assert!(selected.is_err()); + if let std::result::Result::Err(error) = selected { + assert_eq!(error.code(), crate::ERROR_CODE_WALLET_SELECTION_INVALID); + } + } + } + } + cleanup_fixture(root.as_path()); + cleanup_fixture(outside.as_path()); +} + +fn fixture_path(name: &str) -> std::path::PathBuf { + return std::env::temp_dir().join(std::format!("ksp-wallet-desk-{name}-{}", std::process::id())); +} + +fn cleanup_fixture(path: &std::path::Path) { + let metadata = std::fs::symlink_metadata(path); + match metadata { + std::result::Result::Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + let _ = std::fs::remove_dir_all(path); + }, + std::result::Result::Ok(_) => { + let _ = std::fs::remove_file(path); + }, + std::result::Result::Err(_) => {}, + } +} diff --git a/deltas/0.2.6/pre.004.md b/deltas/0.2.6/pre.004.md new file mode 100644 index 0000000..da8ee18 --- /dev/null +++ b/deltas/0.2.6/pre.004.md @@ -0,0 +1,180 @@ + + + +# Delta `0.2.6-pre.004` — inventory `.kspwallet` et inspection locked + +## Base requise + +```text +0.2.6-pre.003-fix.004 appliquée +workspace.package.version = 0.2.6-pre.3.fix.4 +``` + +Les preuves opérateur de `pre.003-fix.004` sont vertes : `cargo fmt --all`, audit structurel Python, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-app-config-desk`, `cargo test --workspace` et `cargo tauri dev -c crates/ksp-app-wallet-desk/tauri.conf.json`. Le runtime utilise le profil composite Logging `supertrace` et démarre correctement. + +## Type de livraison + +```text +ksp-general-0.2.6-pre.004.zip +``` + +Cette tranche ajoute une dépendance interne Wallet et du runtime Rust/frontend ; le signal technique devient : + +```text +workspace.package.version = 0.2.6-pre.4 +commit = v0.2.6-pre.004 +``` + +Aucun tag stable. + +## Objet + +Brancher l'inventory réel des fichiers natifs `.kspwallet` dans Wallet Desk sans introduire encore de password, de handle VIEW/OWNER ni de projection d'identité protégée. + +## Frontière Wallet + +`ksp-app-wallet-desk` dépend désormais directement de `ksp-wallet-lib` et délègue l'inspection locked à : + +```text +ksp_wallet_lib::inspect_locked_wallet_file_v1 +``` + +Aucune crypto, parser `.kspwallet`, keypair ou signature n'est réimplémenté dans l'application. + +## Inventory filesystem + +La racine unique est : + +```text +ResolvedWalletConfig::effective_wallets_directory +``` + +Politique : + +```text +read_dir asynchrone non récursif +filename UTF-8 +suffixe exact .kspwallet +un seul composant filename +slash et backslash rejetés pour les sélections frontend +fichiers réguliers uniquement +symlinks d'entrée exclus +répertoires même nommés *.kspwallet exclus +tri déterministe par filename +``` + +Le bootstrap Config durcit aussi la racine globale : un `wallets_directory` existant sous forme de symlink est désormais refusé au lieu d'être suivi par `metadata()`. + +Une erreur de lecture de la racine fait échouer la commande avec `wallet_desk.wallet_inventory_failed`. Une entrée `.kspwallet` présente mais invalide ne fait pas échouer tout l'inventory : elle reste visible avec état `error`, inspection `invalid` et `CommandErrorDto` sans context/source. + +## DTOs locked + +La tranche ajoute : + +```text +WalletInventoryEntryDto +WalletInventoryStateDto +WalletInspectionStatusDto +LockedWalletDto +WalletSelectionRequestDto +``` + +Une ligne valide expose uniquement : + +```text +wallet_id / filename +format_version +view_enabled +state = locked +inspection_status = valid +``` + +Une ligne invalide expose filename + diagnostic sûr. Aucun DTO ne porte Pubkey, alias, notes, password, path complet ou handle Wallet. + +## Commandes Tauri + +```text +list_wallets +refresh_wallets +select_wallet +``` + +`select_wallet` reçoit uniquement un `wallet_id`/filename, reconstruit le path sous la racine effective, rejette traversal/séparateurs/symlink/non-file puis relance l'inspection locked avant de répondre. La sélection n'installe pas encore un `WalletSession` durable ; celui-ci reste `pre.005`. + +## Frontend + +Le tableau Wallet devient un vrai DataTable alimenté par le backend : + +```text +État -> fa-lock ou fa-triangle-exclamation +Filename -> texte DOM sûr +Format -> version ou — +VIEW -> enabled / disabled / — +Inspection -> Valide ou code diagnostic sûr +``` + +Le bouton Refresh appelle `refresh_wallets`. La sélection d'une ligne valide appelle `select_wallet` et affiche seulement filename/format/VIEW dans la carte « Wallet courant ». Les lignes invalides restent visibles mais ne sont pas sélectionnables. Un refresh purge la projection sélectionnée frontend afin de ne pas conserver une vue stale. + +Le rendu utilise `textContent`/création DOM pour les valeurs issues du filesystem ; aucun filename n'est injecté par HTML. + +## Logging + +Les opérations suivantes sont instrumentées : + +```text +inventory refresh start/completion +invalid locked inspection +non-regular/symlink skip +selection accepted/rejected +frontend inventory load/render/refresh/row selection +``` + +Le profil composite reste `supertrace`; aucun changement de `std.logging` n'est requis. + +## Tests + +Les canaris ajoutés couvrent : + +```text +valid .kspwallet + invalid .kspwallet dans le même inventory +wrong extension ignorée +subdirectory *.kspwallet ignoré +tri déterministe +traversal slash/backslash rejeté +symlink Wallet ignoré et non sélectionnable sur Unix +root wallets_directory symlink rejeté sur Unix +DTO locked sans Pubkey/alias/notes/password +commands Tauri list/refresh/select présentes +frontend DataTable réellement branché sur les DTOs Wallet +``` + +## Dépendances + +Cargo : + +```text +ksp-wallet-lib = { path = "../ksp-wallet-lib" } +tokio features += fs, rt +``` + +Aucune dépendance npm et aucun script npm ne changent. **Aucune commande npm directe n'est nécessaire pour cette tranche.** + +## Validation opérateur requise + +```bash +cargo fmt --all +python3 scripts/audit_rust_workspace_rules.py +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-app-wallet-desk +cargo test --workspace +KSP_WALLETS_DIRECTORY=var/wallet-desk-pre004 cargo tauri dev -c crates/ksp-app-wallet-desk/tauri.conf.json +``` + +Pour la vérification UI, placer si souhaité un ou plusieurs `.kspwallet` existants dans `var/wallet-desk-pre004`, ainsi qu'un fichier `invalid.kspwallet` non valide, puis tester recherche/tri, Refresh et sélection des lignes valides. + +Toujours aucun `cargo tauri build` à ce stade. + +## Suite + +Après validation de `pre.004`, `pre.005` introduira la création de Wallet et le lifecycle/session Rust sans avancer encore la balance HTTP. diff --git a/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md b/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md index 02b5286..d92c0ee 100644 --- a/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md +++ b/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md @@ -1,5 +1,5 @@ - + # Séquence des releases fonctionnelles KSP @@ -430,7 +430,7 @@ Le prompt [`../../prompts/011-V0_2_6_START_PROMPT.md`](../../prompts/011-V0_2_6_ `0.2.6-pre.001` confirme que le registre Config doit être étendu pour `cfg.std.wallet`/`schema.std.wallet` et `cfg.composite.ksp-app-wallet-desk`. `wallets_directory` reste global avec fallback `${KSP_WALLETS_DIRECTORY:-wallets}` et chaque profil peut définir un `wallets_subdirectory` relatif pour isoler tests, temporaires ou futurs scénarios. Wallet Desk prépare/crée le répertoire effectif après résolution Config et journalise le succès en debug ou l'échec en error. Le gabarit Config Desk est réutilisé avec Bootstrap, Font Awesome, DataTables/Select, SimpleBar et `resize-observer-polyfill`. L'inventory reste non récursive `.kspwallet`, sans symlink d'entrée et root-scoped; les handles VIEW/OWNER restent exclusivement côté Rust. L'ouverture accepte un password éphémère saisi ou une tentative explicite de secrets Config `KSP_SECRET_WALLET_PASS_*`, dont ni nom ni valeur ne traversent le frontend. `getBalance` utilise la Pubkey du handle autorisé et les flows OWNER metadata/rotations/revocation/export fichier délèguent à Wallet. La CSP reste `null` par défaut comme dans Config Desk et n'est réauditée qu'en présence d'un besoin WebView concret. Le forecast souple détaillé s'étend jusqu'à `pre.014`, dernière tranche prévue pour README/USAGE/docs/validation, prompt `0.2.7` et build Tauri final; le plan actif est `docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md`. -`0.2.6-pre.002` matérialise le shell Tauri spécialisé. `0.2.6-pre.003` matérialise ensuite la première composition Config propre à Wallet Desk : `cfg.std.wallet`/`schema.std.wallet`, le composite `cfg.composite.ksp-app-wallet-desk`, `ResolvedWalletConfig`, la racine `${KSP_WALLETS_DIRECTORY:-wallets}`, les sous-répertoires de profils `temporary`/`tests`, la préservation de provenance `Composite` dans les adapters Logging/Wallet et la création sûre des répertoires par l’application. Aucun inventory ou fichier `.kspwallet` n’est encore ouvert dans cette tranche. `0.2.6-pre.003-fix.001` corrige ensuite les tests de registry/fixtures devenus obsolètes et instrumente les clics frontend génériques sans sérialiser la valeur des contrôles. `0.2.6-pre.003-fix.002` corrige le scanner d’inventaire `.env.example` qui interprétait `FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK` comme une variable d’environnement, remplace le profil Logging ad hoc Wallet Desk par les profils génériques console-only/`file_info`/`superdev`/`supertrace`, et sélectionne temporairement `supertrace` dans le composite Wallet Desk. `0.2.6-pre.003-fix.003` corrige les deux canaris révélés par la validation opérateur de `fix.002` sans modifier cette surface Logging : closures avec `return` explicite sous la politique Clippy KSP et test lexical séparant fragment d’identifiant d’un vrai nom KSP suffixé. `0.2.6-pre.003-fix.004` complète cette remédiation en ajoutant les `return` explicites aux cinq closures restantes du canari `config_composition` de Wallet Desk ; aucune surface Config/Logging/frontend n’est modifiée. +`0.2.6-pre.002` matérialise le shell Tauri spécialisé. `0.2.6-pre.003` matérialise ensuite la première composition Config propre à Wallet Desk : `cfg.std.wallet`/`schema.std.wallet`, le composite `cfg.composite.ksp-app-wallet-desk`, `ResolvedWalletConfig`, la racine `${KSP_WALLETS_DIRECTORY:-wallets}`, les sous-répertoires de profils `temporary`/`tests`, la préservation de provenance `Composite` dans les adapters Logging/Wallet et la création sûre des répertoires par l’application. Aucun inventory ou fichier `.kspwallet` n’est encore ouvert dans cette tranche. `0.2.6-pre.003-fix.001` corrige ensuite les tests de registry/fixtures devenus obsolètes et instrumente les clics frontend génériques sans sérialiser la valeur des contrôles. `0.2.6-pre.003-fix.002` corrige le scanner d’inventaire `.env.example` qui interprétait `FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK` comme une variable d’environnement, remplace le profil Logging ad hoc Wallet Desk par les profils génériques console-only/`file_info`/`superdev`/`supertrace`, et sélectionne temporairement `supertrace` dans le composite Wallet Desk. `0.2.6-pre.003-fix.003` corrige les deux canaris révélés par la validation opérateur de `fix.002` sans modifier cette surface Logging : closures avec `return` explicite sous la politique Clippy KSP et test lexical séparant fragment d’identifiant d’un vrai nom KSP suffixé. `0.2.6-pre.003-fix.004` complète cette remédiation en ajoutant les `return` explicites aux cinq closures restantes du canari `config_composition` de Wallet Desk ; aucune surface Config/Logging/frontend n’est modifiée. `0.2.6-pre.004` ajoute l’inventory `.kspwallet` réel sous `effective_wallets_directory` : lecture non récursive, extension exacte, exclusion des symlinks et non-fichiers, inspection par `ksp-wallet-lib::inspect_locked_wallet_file_v1`, lignes invalides conservées avec diagnostic sûr, DTOs locked sans identité protégée, DataTable/refresh actifs et sélection filename root-scoped revalidée côté Rust. Aucun handle VIEW/OWNER ni password n’est introduit dans cette tranche. ## `0.2.7` — WebSocket Solana standard diff --git a/docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md b/docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md index 6515147..c04fc7a 100644 --- a/docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md +++ b/docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.2.6` — Wallet Desk @@ -1307,6 +1307,27 @@ refresh manuel + après opérations locales futures À la fin de cette tranche, l'application sait lister et sélectionner un wallet sans révéler son identité protégée. +État matérialisé par `pre.004` : + +```text +ksp-app-wallet-desk -> ksp-wallet-lib ajouté explicitement +effective_wallets_directory comme unique racine d'inventory +read_dir asynchrone non récursif +filename UTF-8 + suffixe exact .kspwallet +regular file uniquement ; symlink/non-file exclus +inspection via inspect_locked_wallet_file_v1 +ligne invalide conservée avec CommandErrorDto sûr +WalletInventoryEntryDto / LockedWalletDto / WalletSelectionRequestDto +list_wallets / refresh_wallets / select_wallet +DataTable réel + refresh + sélection +fa-lock pour valid locked ; fa-triangle-exclamation pour invalid +selection revalidée côté Rust, filename single-component uniquement +root symlink désormais rejeté lors de la préparation Config +aucun Pubkey / alias / notes / password dans la projection locked +``` + +La sélection de `pre.004` reste une projection locked sans handle durable : le `WalletSession` Rust et les handles OWNER/VIEW arrivent en `pre.005`/`pre.006`. Un refresh invalide donc la sélection frontend pour éviter de conserver une projection potentiellement stale. + ### `pre.005` — création Wallet et lifecycle de session Rust Objectifs :