v0.2.6-pre.012

This commit is contained in:
2026-08-21 16:22:16 +02:00
parent 8ba554306a
commit ecbd6b746d
21 changed files with 634 additions and 94 deletions

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/app_state.rs
// version: 15
// version: 16
//! Shared backend state owned by the Wallet Desk Tauri application.
@@ -126,7 +126,7 @@ 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.010-credential-rotation".to_owned(),
shell_phase: "pre.012-owner-transfer-export".to_owned(),
startup_diagnostic: runtime.startup_diagnostic.clone(),
transport_available_endpoint_count,
transport_clusters: self.transport_runtime.clusters(),
@@ -722,6 +722,71 @@ impl AppState {
return self.finish_owner_operation(context, result).await;
}
/// Returns the native save-picker default filename when an OWNER session is currently authorized.
pub(crate) fn wallet_export_picker_filename(&self, format: crate::WalletTransferFormatDto) -> ksp_core_lib::Result<String> {
let session = self.wallet_session.lock();
let session = match session {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(session_lock_error()),
};
return match &*session {
crate::WalletSession::Owner { wallet_id, .. } => std::result::Result::Ok(crate::wallet_export_default_filename(wallet_id.as_str(), format)),
_ => std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_AUTHORIZATION_REQUIRED,
"Wallet transfer export requires an authorized OWNER session",
)),
};
}
/// Exports the immutable Solana keypair to one Rust-owned native save-picker destination.
pub(crate) async fn export_wallet_transfer(
&self,
destination: std::path::PathBuf,
request: crate::WalletExportRequestDto,
) -> ksp_core_lib::Result<crate::WalletExportResultDto> {
let destination_name = crate::wallet_export_destination_name(destination.as_path());
let destination_name = match destination_name {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let format = request.format;
let context = self.begin_owner_operation("keypair_export");
let context = match context {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let wallet_id = context.wallet_id.clone();
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WALLET_EXPORT,
wallet_id = wallet_id.as_str(),
transfer_format = format.code(),
"Wallet OWNER transfer export started"
);
let result = context.owner.export_transfer_file(destination.as_path(), format.wallet_format()).await;
let restored = self.finish_owner_operation(context, result).await;
if let std::result::Result::Err(error) = restored {
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WALLET_EXPORT,
wallet_id = wallet_id.as_str(),
transfer_format = format.code(),
error_domain = error.code().domain(),
error_code = error.code().code(),
"Wallet OWNER transfer export failed"
);
return std::result::Result::Err(error);
}
ksp_logging_lib::info!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_WALLET_EXPORT,
wallet_id = wallet_id.as_str(),
transfer_format = format.code(),
"Wallet OWNER transfer export completed"
);
return std::result::Result::Ok(crate::WalletExportResultDto { destination_name, format, wallet_id });
}
/// Strongly disables VIEW from the current OWNER session while preserving OWNER authorization.
pub(crate) async fn disable_wallet_view(&self) -> ksp_core_lib::Result<crate::WalletViewSecurityStatusDto> {
let context = self.begin_owner_operation("view_disable");

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/constants.rs
// version: 7
// version: 8
//! Logging targets, domains and composite component identifiers owned by Wallet Desk.
@@ -19,6 +19,8 @@ pub(crate) const TRACING_DOMAIN_SHELL: &str = "wallet.shell";
pub(crate) const TRACING_DOMAIN_TRANSPORT: &str = "wallet.transport";
/// Structured domain used while preparing Wallet filesystem roots.
pub(crate) const TRACING_DOMAIN_WALLET_CONFIG: &str = "wallet.config";
/// Structured domain used by native-save-picker OWNER transfer export operations.
pub(crate) const TRACING_DOMAIN_WALLET_EXPORT: &str = "wallet.export";
/// Structured domain used by native-picker Wallet transfer import operations.
pub(crate) const TRACING_DOMAIN_WALLET_IMPORT: &str = "wallet.import";
/// Structured domain used while enumerating and inspecting locked Wallet files.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/errors.rs
// version: 7
// version: 8
//! Application-local error codes for Wallet Desk composition and desktop runtime surfaces.
@@ -34,6 +34,9 @@ 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");
/// A native save-picker Wallet export destination is not a usable local filesystem path.
pub(crate) const ERROR_CODE_WALLET_EXPORT_DESTINATION_INVALID: ksp_core_lib::ErrorCode =
ksp_core_lib::ErrorCode::new("wallet_desk", "wallet_export_destination_invalid");
/// A native-picker Wallet transfer source is not a supported regular file or cannot be staged safely.
pub(crate) const ERROR_CODE_WALLET_IMPORT_SOURCE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("wallet_desk", "wallet_import_source_invalid");
/// Wallet import was requested without one previously inspected native-picker source.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/lib.rs
// version: 12
// version: 13
//! Tauri desktop application shell for KSP Wallet management and inspection.
@@ -21,12 +21,14 @@ mod tw_main;
mod tw_splash;
mod wallet_balance;
mod wallet_config;
mod wallet_export;
mod wallet_import;
mod wallet_inventory;
mod wallet_metadata;
mod wallet_secrets;
mod wallet_security;
mod wallet_session;
mod wallet_transfer;
/// Runs the KSP wallet desktop application.
pub use self::tauri::run;
@@ -59,6 +61,8 @@ pub(crate) use self::constants::TRACING_DOMAIN_SHELL;
pub(crate) use self::constants::TRACING_DOMAIN_TRANSPORT;
/// Structured domain used while preparing Wallet filesystem roots.
pub(crate) use self::constants::TRACING_DOMAIN_WALLET_CONFIG;
/// Structured domain used by native-save-picker OWNER transfer export operations.
pub(crate) use self::constants::TRACING_DOMAIN_WALLET_EXPORT;
/// Structured domain used by native-picker Wallet transfer import operations.
pub(crate) use self::constants::TRACING_DOMAIN_WALLET_IMPORT;
/// Structured domain used while enumerating and inspecting locked Wallet files.
@@ -111,6 +115,8 @@ pub(crate) use self::errors::ERROR_CODE_WALLET_AUTHORIZATION_REQUIRED;
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;
/// Native save-picker Wallet export destination is invalid.
pub(crate) use self::errors::ERROR_CODE_WALLET_EXPORT_DESTINATION_INVALID;
/// Native-picker transfer source is invalid or cannot be staged.
pub(crate) use self::errors::ERROR_CODE_WALLET_IMPORT_SOURCE_INVALID;
/// Wallet import has no staged native-picker source.
@@ -155,14 +161,20 @@ pub(crate) use self::wallet_balance::format_lamports_as_sol;
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;
/// OWNER transfer export request carrying only the format selected by the frontend.
pub(crate) use self::wallet_export::WalletExportRequestDto;
/// Safe OWNER transfer export result without path or key material.
pub(crate) use self::wallet_export::WalletExportResultDto;
/// Builds the deterministic native save-picker filename for the current OWNER Wallet.
pub(crate) use self::wallet_export::wallet_export_default_filename;
/// Extracts a safe basename from the Rust-only native save-picker destination.
pub(crate) use self::wallet_export::wallet_export_destination_name;
/// Rust-only staged import source containing bounded zeroizing transfer bytes.
pub(crate) use self::wallet_import::PendingWalletImport;
/// Import request whose new native Wallet credentials move frontend -> Rust only.
pub(crate) use self::wallet_import::WalletImportRequestDto;
/// Native picker request containing only the expected transfer format.
pub(crate) use self::wallet_import::WalletImportSourceRequestDto;
/// Safe transfer format DTO used by the import picker.
pub(crate) use self::wallet_import::WalletTransferFormatDto;
/// Safe inspection projection for one staged external transfer source.
pub(crate) use self::wallet_import::WalletTransferInspectionDto;
/// Loads and validates one native-picker import source into bounded zeroizing Rust memory.
@@ -225,3 +237,5 @@ pub(crate) use self::wallet_session::WalletUnlockRequestDto;
pub(crate) use self::wallet_session::owner_projection;
/// Builds an authorized VIEW DTO from a Rust-only Wallet handle.
pub(crate) use self::wallet_session::view_projection;
/// Shared import/export transfer format selected by Wallet Desk.
pub(crate) use self::wallet_transfer::WalletTransferFormatDto;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/tauri.rs
// version: 9
// version: 10
//! Tauri runtime assembly for the KSP wallet desktop application.
@@ -47,6 +47,7 @@ fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tau
deselect_wallet,
disable_wallet_view,
emit_frontend_log,
export_wallet_transfer,
get_runtime_status,
import_wallet,
inspect_import_source,
@@ -155,6 +156,51 @@ fn emit_frontend_log(payload: crate::FrontendLogPayloadDto) -> std::result::Resu
};
}
#[tauri::command]
async fn export_wallet_transfer(
app: tauri::AppHandle,
request: crate::WalletExportRequestDto,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<std::option::Option<crate::WalletExportResultDto>, crate::CommandErrorDto> {
let format = request.format;
let default_filename = state.wallet_export_picker_filename(format);
let default_filename = match default_filename {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
let extensions = [format.default_extension()];
let selected = app
.dialog()
.file()
.set_title("Export Solana keypair — secret material")
.set_file_name(default_filename)
.add_filter("Solana keypair transfer", &extensions)
.blocking_save_file();
let selected = match selected {
std::option::Option::Some(value) => value,
std::option::Option::None => {
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_EXPORT, transfer_format = format.code(), "Wallet OWNER transfer export native save picker cancelled");
return std::result::Result::Ok(std::option::Option::None);
},
};
let path = selected.into_path();
let path = match path {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
let error = ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_EXPORT_DESTINATION_INVALID,
"Selected Wallet transfer export destination is not a local filesystem path",
);
return std::result::Result::Err(crate::CommandErrorDto::from_error(&error));
},
};
let result = state.export_wallet_transfer(path, request).await;
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value)),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}
#[tauri::command]
fn get_runtime_status(state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::RuntimeStatusDto, crate::CommandErrorDto> {
let result = state.runtime_status();

View File

@@ -0,0 +1,52 @@
// file: crates/ksp-app-wallet-desk/src/wallet_export.rs
// version: 1
//! OWNER-only native save-picker Wallet transfer export contracts.
use ts_rs::TS; // rust-rules: trait-import
/// OWNER export request containing only the explicit transfer format.
#[derive(serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_export/WalletExportRequestDto.ts")]
pub(crate) struct WalletExportRequestDto {
/// Secret-transfer encoding selected before the Rust-owned save picker opens.
pub(crate) format: crate::WalletTransferFormatDto,
}
/// Safe result returned after an OWNER keypair export completes.
#[derive(serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_export/WalletExportResultDto.ts")]
pub(crate) struct WalletExportResultDto {
/// Basename only for the newly created export file; the parent path remains Rust-only.
pub(crate) destination_name: String,
/// Explicit transfer format used by the export.
pub(crate) format: crate::WalletTransferFormatDto,
/// Root-scoped native Wallet identifier that authorized the export.
pub(crate) wallet_id: String,
}
/// Builds a deterministic native-save default filename without exposing Wallet metadata or Pubkey.
#[must_use]
pub(crate) fn wallet_export_default_filename(wallet_id: &str, format: crate::WalletTransferFormatDto) -> String {
let stem = wallet_id.strip_suffix(crate::WALLET_FILE_SUFFIX).unwrap_or(wallet_id);
let stem = if stem.is_empty() { "wallet" } else { stem };
return format!("{stem}-solana-keypair.{}", format.default_extension());
}
/// Extracts one safe basename from a native save-picker destination.
pub(crate) fn wallet_export_destination_name(destination: &std::path::Path) -> ksp_core_lib::Result<String> {
let name = destination.file_name().and_then(std::ffi::OsStr::to_str);
return match name {
std::option::Option::Some(value) if !value.is_empty() => std::result::Result::Ok(value.to_owned()),
_ => std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_WALLET_EXPORT_DESTINATION_INVALID,
"Selected Wallet export destination has no usable local filename",
)),
};
}
#[cfg(test)]
#[path = "../unit_tests/wallet_export.rs"]
mod tests;

View File

@@ -1,65 +1,18 @@
// file: crates/ksp-app-wallet-desk/src/wallet_import.rs
// version: 2
// version: 3
//! Native-picker transfer import staging with bounded secret material kept only in Rust.
use tokio::io::AsyncReadExt; // rust-rules: trait-import
use ts_rs::TS; // rust-rules: trait-import
/// Transfer format selected before opening the native import picker.
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_import/WalletTransferFormatDto.ts")]
pub(crate) enum WalletTransferFormatDto {
/// Standard Solana CLI keypair JSON array containing 64 byte values.
SolanaCliJson,
/// Canonical Base58 encoding of the complete 64-byte Solana keypair.
SolanaKeypairBase58,
}
impl WalletTransferFormatDto {
/// Maps the desktop DTO to the stable Wallet transfer format.
#[must_use]
pub(crate) const fn wallet_format(self) -> ksp_wallet_lib::WalletTransferFormat {
return match self {
Self::SolanaCliJson => ksp_wallet_lib::WalletTransferFormat::SolanaCliJson,
Self::SolanaKeypairBase58 => ksp_wallet_lib::WalletTransferFormat::SolanaKeypairBase58,
};
}
/// Returns the native picker extension used as a convenience filter.
#[must_use]
pub(crate) const fn default_extension(self) -> &'static str {
return match self {
Self::SolanaCliJson => "json",
Self::SolanaKeypairBase58 => "txt",
};
}
/// Returns the stable transfer format code used by safe diagnostics.
#[must_use]
pub(crate) const fn code(self) -> &'static str {
return match self {
Self::SolanaCliJson => "solana_cli_json",
Self::SolanaKeypairBase58 => "solana_keypair_base58",
};
}
const fn maximum_source_bytes(self) -> usize {
return match self {
Self::SolanaCliJson => ksp_wallet_lib::KSPWALLET_TRANSFER_MAX_SOLANA_CLI_JSON_BYTES,
Self::SolanaKeypairBase58 => ksp_wallet_lib::KSPWALLET_TRANSFER_MAX_BASE58_BYTES,
};
}
}
/// Request that chooses only the transfer format; the source path comes from the native Rust picker.
#[derive(serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_import/WalletImportSourceRequestDto.ts")]
pub(crate) struct WalletImportSourceRequestDto {
/// Transfer encoding expected for the selected external file.
pub(crate) format: WalletTransferFormatDto,
pub(crate) format: crate::WalletTransferFormatDto,
}
/// Safe projection returned after validating one native-picker import source.
@@ -68,7 +21,7 @@ pub(crate) struct WalletImportSourceRequestDto {
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_import/WalletTransferInspectionDto.ts")]
pub(crate) struct WalletTransferInspectionDto {
/// Transfer format used to validate the secret source.
pub(crate) format: WalletTransferFormatDto,
pub(crate) format: crate::WalletTransferFormatDto,
/// Public identity derived from the imported keypair.
pub(crate) pubkey: String,
/// Basename only; no parent path is ever projected.
@@ -95,7 +48,7 @@ pub(crate) struct WalletImportRequestDto {
/// Rust-only staged transfer source. Secret bytes are bounded and zeroized on drop.
pub(crate) struct PendingWalletImport {
bytes: zeroize::Zeroizing<std::vec::Vec<u8>>,
format: WalletTransferFormatDto,
format: crate::WalletTransferFormatDto,
inspection: WalletTransferInspectionDto,
}
@@ -120,7 +73,10 @@ impl PendingWalletImport {
}
/// Reads, bounds and validates one native-picker transfer source while retaining no external path.
pub(crate) async fn stage_wallet_import_source(source: std::path::PathBuf, format: WalletTransferFormatDto) -> ksp_core_lib::Result<PendingWalletImport> {
pub(crate) async fn stage_wallet_import_source(
source: std::path::PathBuf,
format: crate::WalletTransferFormatDto,
) -> ksp_core_lib::Result<PendingWalletImport> {
let metadata = tokio::fs::symlink_metadata(source.as_path()).await;
let metadata = match metadata {
std::result::Result::Ok(value) => value,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-wallet-desk/src/wallet_session.rs
// version: 7
// version: 8
//! Durable root-scoped Wallet session lifecycle for Wallet Desk.
@@ -170,7 +170,7 @@ pub(crate) enum WalletSession {
/// Whether the authenticated Wallet currently exposes a VIEW slot.
view_enabled: bool,
},
/// One OWNER-authorized privileged mutation temporarily owns the handle outside the session mutex.
/// One OWNER-authorized privileged operation temporarily owns the handle outside the session mutex.
OwnerOperation {
/// Native Wallet filename / root-scoped identifier.
wallet_id: String,

View File

@@ -0,0 +1,59 @@
// file: crates/ksp-app-wallet-desk/src/wallet_transfer.rs
// version: 1
//! Shared Wallet Desk transfer format contract used by import and OWNER export workflows.
use ts_rs::TS; // rust-rules: trait-import
/// Explicit Solana keypair transfer format selected by Wallet Desk.
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_wallet_desk/wallet_transfer/WalletTransferFormatDto.ts")]
pub(crate) enum WalletTransferFormatDto {
/// Standard Solana CLI keypair JSON array containing 64 byte values.
SolanaCliJson,
/// Canonical Base58 encoding of the complete 64-byte Solana keypair.
SolanaKeypairBase58,
}
impl WalletTransferFormatDto {
/// Maps the desktop DTO to the stable Wallet transfer format.
#[must_use]
pub(crate) const fn wallet_format(self) -> ksp_wallet_lib::WalletTransferFormat {
return match self {
Self::SolanaCliJson => ksp_wallet_lib::WalletTransferFormat::SolanaCliJson,
Self::SolanaKeypairBase58 => ksp_wallet_lib::WalletTransferFormat::SolanaKeypairBase58,
};
}
/// Returns the native picker/save extension used as a convenience filter.
#[must_use]
pub(crate) const fn default_extension(self) -> &'static str {
return match self {
Self::SolanaCliJson => "json",
Self::SolanaKeypairBase58 => "txt",
};
}
/// Returns the stable transfer format code used by safe diagnostics.
#[must_use]
pub(crate) const fn code(self) -> &'static str {
return match self {
Self::SolanaCliJson => "solana_cli_json",
Self::SolanaKeypairBase58 => "solana_keypair_base58",
};
}
/// Returns the maximum accepted import source size for this transfer encoding.
#[must_use]
pub(crate) const fn maximum_source_bytes(self) -> usize {
return match self {
Self::SolanaCliJson => ksp_wallet_lib::KSPWALLET_TRANSFER_MAX_SOLANA_CLI_JSON_BYTES,
Self::SolanaKeypairBase58 => ksp_wallet_lib::KSPWALLET_TRANSFER_MAX_BASE58_BYTES,
};
}
}
#[cfg(test)]
#[path = "../unit_tests/wallet_transfer.rs"]
mod tests;