// file: crates/ksp-app-wallet-desk/src/wallet_inventory.rs // version: 4 //! 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 { /// Number of effective Config-owned Wallet password candidates without their names or values. pub(crate) configured_secret_candidate_count: usize, /// 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(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. #[cfg(test)] pub(crate) async fn select_locked_wallet(root: &std::path::Path, request: WalletSelectionRequestDto) -> ksp_core_lib::Result { let resolved = resolve_locked_wallet(root, request.wallet_id).await; return match resolved { std::result::Result::Ok((_, _, dto)) => std::result::Result::Ok(dto), std::result::Result::Err(error) => std::result::Result::Err(error), }; } /// Resolves one locked Wallet into its Rust-only path, locked info and safe DTO. pub(crate) async fn resolve_locked_wallet( root: &std::path::Path, wallet_id: String, ) -> ksp_core_lib::Result<(std::path::PathBuf, ksp_wallet_lib::LockedWalletInfo, LockedWalletDto)> { let path = resolve_wallet_path(root, 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(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), }; let dto = LockedWalletDto { configured_secret_candidate_count: 0, filename: wallet_id.clone(), format_version: locked.format_version(), wallet_id: wallet_id.clone(), view_enabled: locked.view_enabled(), }; ksp_logging_lib::debug!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_WALLET_INVENTORY, wallet_id = wallet_id.as_str(), format_version = locked.format_version(), view_enabled = locked.view_enabled(), "Locked Wallet selected from inventory"); return std::result::Result::Ok((path, locked, dto)); } /// Validates a root-scoped destination filename for a new native Wallet. pub(crate) fn new_wallet_destination(root: &std::path::Path, filename: &str) -> ksp_core_lib::Result { if !is_wallet_filename(filename) { return selection_invalid(filename, "Wallet destination must be one UTF-8 filename ending in .kspwallet"); } return std::result::Result::Ok(root.join(filename)); } 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;