259 lines
13 KiB
Rust
259 lines
13 KiB
Rust
// 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<crate::CommandErrorDto>,
|
|
/// 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<u32>,
|
|
/// 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<bool>,
|
|
}
|
|
|
|
/// 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<std::vec::Vec<WalletInventoryEntryDto>> {
|
|
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<LockedWalletDto> {
|
|
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<std::path::PathBuf> {
|
|
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<std::path::PathBuf> {
|
|
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<T>(wallet_id: &str, reason: &'static str) -> ksp_core_lib::Result<T> {
|
|
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;
|