695 lines
29 KiB
Rust
695 lines
29 KiB
Rust
// file: ks-wallet/src/manager.rs
|
|
// version: 6
|
|
|
|
//! Multi-wallet discovery, authenticated opening and password rotation.
|
|
|
|
use solana_signer::Signer; // rust-rules: trait-import
|
|
|
|
/// Opaque reference to one validated native wallet file.
|
|
///
|
|
/// The local path is intentionally kept private and is not included in `Debug`.
|
|
/// Consumers keep this handle and pass it back to `ks-wallet` for future open
|
|
/// operations instead of reading the file directly.
|
|
#[derive(Clone, Eq, PartialEq)]
|
|
pub struct WalletFileHandle {
|
|
alias: crate::WalletAlias,
|
|
public_key: std::string::String,
|
|
format_version: u16,
|
|
path: std::path::PathBuf,
|
|
}
|
|
|
|
impl std::fmt::Debug for crate::WalletFileHandle {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
return formatter
|
|
.debug_struct("WalletFileHandle")
|
|
.field("alias", &self.alias)
|
|
.field("public_key", &self.public_key)
|
|
.field("format_version", &self.format_version)
|
|
.finish();
|
|
}
|
|
}
|
|
|
|
impl crate::WalletFileHandle {
|
|
/// Returns the validated alias encoded by the native wallet and filename.
|
|
pub fn alias(&self) -> &crate::WalletAlias {
|
|
return &self.alias;
|
|
}
|
|
|
|
/// Returns the base58 public key declared by the native wallet header before unlock.
|
|
pub fn public_key(&self) -> &str {
|
|
return self.public_key.as_str();
|
|
}
|
|
|
|
/// Returns the native wallet format version.
|
|
pub fn format_version(&self) -> u16 {
|
|
return self.format_version;
|
|
}
|
|
|
|
/// Returns the non-secret persistent identity declared before authenticated unlock.
|
|
pub fn identity(&self) -> crate::WalletIdentity {
|
|
return crate::WalletIdentity {
|
|
alias: self.alias.clone(),
|
|
public_key: self.public_key.clone(),
|
|
persistence: crate::WalletPersistence::Persistent,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Multi-wallet manager rooted at one configured wallet directory.
|
|
///
|
|
/// Automatic discovery is limited to this configured directory. A caller may
|
|
/// also inspect an explicitly selected `.kswallet` file outside the configured
|
|
/// directory; doing so does not change the manager directory and does not add
|
|
/// the external file to automatic discovery.
|
|
pub struct WalletManager {
|
|
directory: std::path::PathBuf,
|
|
}
|
|
|
|
impl crate::WalletManager {
|
|
/// Creates a manager rooted at the supplied configured wallet directory.
|
|
pub fn new(directory: impl std::convert::Into<std::path::PathBuf>) -> ks_core::Result<Self> {
|
|
let directory = directory.into();
|
|
if directory.as_os_str().is_empty() {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_directory_empty",
|
|
"wallet directory must not be empty",
|
|
));
|
|
}
|
|
return std::result::Result::Ok(Self { directory });
|
|
}
|
|
|
|
/// Returns the configured directory used for automatic wallet discovery.
|
|
pub fn directory(&self) -> &std::path::Path {
|
|
return self.directory.as_path();
|
|
}
|
|
|
|
fn wallet_path(&self, alias: &crate::WalletAlias) -> std::path::PathBuf {
|
|
return self.directory.join(format!(
|
|
"{}.{}",
|
|
alias.as_str(),
|
|
crate::KSWALLET_FILE_EXTENSION
|
|
));
|
|
}
|
|
|
|
/// Discovers structurally valid native wallet containers in the configured directory.
|
|
///
|
|
/// Discovery is non-recursive and ignores unrelated file extensions. Every
|
|
/// `.kswallet` candidate must be a private regular file whose complete
|
|
/// version-one container decodes strictly and whose filename alias matches
|
|
/// the alias encoded by the native header.
|
|
pub async fn scan(&self) -> ks_core::Result<std::vec::Vec<crate::WalletFileHandle>> {
|
|
let directory_exists = match tokio::fs::try_exists(&self.directory).await {
|
|
std::result::Result::Ok(exists) => exists,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_directory_exists_check_failed",
|
|
error.to_string(),
|
|
));
|
|
},
|
|
};
|
|
if !directory_exists {
|
|
return std::result::Result::Ok(std::vec::Vec::new());
|
|
}
|
|
match validate_wallet_directory(&self.directory).await {
|
|
std::result::Result::Ok(()) => {},
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
}
|
|
let mut entries = match tokio::fs::read_dir(&self.directory).await {
|
|
std::result::Result::Ok(entries) => entries,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_directory_read_failed",
|
|
error.to_string(),
|
|
));
|
|
},
|
|
};
|
|
let mut wallets = std::vec::Vec::new();
|
|
loop {
|
|
let entry = match entries.next_entry().await {
|
|
std::result::Result::Ok(std::option::Option::Some(entry)) => entry,
|
|
std::result::Result::Ok(std::option::Option::None) => break,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_directory_entry_read_failed",
|
|
error.to_string(),
|
|
));
|
|
},
|
|
};
|
|
let path = entry.path();
|
|
if !has_native_wallet_extension(&path) {
|
|
continue;
|
|
}
|
|
let wallet = match inspect_native_wallet_file(path).await {
|
|
std::result::Result::Ok(wallet) => wallet,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
wallets.push(wallet);
|
|
}
|
|
wallets.sort_by(|left, right| return left.alias.cmp(&right.alias));
|
|
return std::result::Result::Ok(wallets);
|
|
}
|
|
|
|
/// Looks up one native wallet by alias in the configured directory.
|
|
pub async fn lookup(
|
|
&self,
|
|
alias: &crate::WalletAlias,
|
|
) -> ks_core::Result<std::option::Option<crate::WalletFileHandle>> {
|
|
let path = self.wallet_path(alias);
|
|
let exists = match tokio::fs::try_exists(&path).await {
|
|
std::result::Result::Ok(exists) => exists,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_file_exists_check_failed",
|
|
error.to_string(),
|
|
));
|
|
},
|
|
};
|
|
if !exists {
|
|
return std::result::Result::Ok(std::option::Option::None);
|
|
}
|
|
let wallet = match inspect_native_wallet_file(path).await {
|
|
std::result::Result::Ok(wallet) => wallet,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
return std::result::Result::Ok(std::option::Option::Some(wallet));
|
|
}
|
|
|
|
/// Inspects an explicitly selected native wallet file at any local path.
|
|
///
|
|
/// The selected path does not need to be inside the configured discovery
|
|
/// directory. Successful inspection does not register, copy or move it.
|
|
pub async fn inspect_file(
|
|
&self,
|
|
path: impl std::convert::AsRef<std::path::Path>,
|
|
) -> ks_core::Result<crate::WalletFileHandle> {
|
|
return inspect_native_wallet_file(path.as_ref().to_path_buf()).await;
|
|
}
|
|
|
|
/// Creates and atomically persists one new password-protected native wallet.
|
|
pub async fn create(
|
|
&self,
|
|
alias: crate::WalletAlias,
|
|
password: crate::WalletPassword,
|
|
) -> ks_core::Result<crate::UnlockedWallet> {
|
|
let path = self.wallet_path(&alias);
|
|
let exists = match tokio::fs::try_exists(&path).await {
|
|
std::result::Result::Ok(exists) => exists,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_file_exists_check_failed",
|
|
error.to_string(),
|
|
));
|
|
},
|
|
};
|
|
if exists {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_already_exists",
|
|
"native wallet already exists for this alias",
|
|
));
|
|
}
|
|
let keypair = solana_keypair::Keypair::new();
|
|
let public_key = keypair.pubkey();
|
|
let keypair_bytes = zeroize::Zeroizing::new(keypair.to_bytes());
|
|
let container = match crate::protect_native_wallet_keypair(
|
|
alias.clone(),
|
|
public_key,
|
|
keypair_bytes,
|
|
password,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(container) => container,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
match crate::write_native_wallet_file_atomic(path, &container).await {
|
|
std::result::Result::Ok(()) => {},
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
}
|
|
tracing::info!(
|
|
target: crate::TRACING_TARGET,
|
|
action = "create_native_wallet",
|
|
wallet_alias = alias.as_str(),
|
|
public_key = %public_key,
|
|
"created password-protected native wallet"
|
|
);
|
|
return std::result::Result::Ok(crate::UnlockedWallet::new(alias, keypair));
|
|
}
|
|
|
|
/// Authenticates and unlocks one native wallet from the configured directory.
|
|
pub async fn unlock(
|
|
&self,
|
|
alias: &crate::WalletAlias,
|
|
password: crate::WalletPassword,
|
|
) -> ks_core::Result<crate::UnlockedWallet> {
|
|
let path = self.wallet_path(alias);
|
|
let exists = match tokio::fs::try_exists(&path).await {
|
|
std::result::Result::Ok(exists) => exists,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_file_exists_check_failed",
|
|
error.to_string(),
|
|
));
|
|
},
|
|
};
|
|
if !exists {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_not_found",
|
|
"native wallet was not found for this alias",
|
|
));
|
|
}
|
|
return unlock_native_wallet_at_path(
|
|
path,
|
|
std::option::Option::Some(alias),
|
|
std::option::Option::None,
|
|
password,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
/// Authenticates an explicitly selected native wallet file outside or inside the store.
|
|
///
|
|
/// The opaque handle is revalidated against the file before any signing
|
|
/// capability is returned. The file is never registered in the configured store.
|
|
pub async fn unlock_file(
|
|
&self,
|
|
handle: &crate::WalletFileHandle,
|
|
password: crate::WalletPassword,
|
|
) -> ks_core::Result<crate::UnlockedWallet> {
|
|
return unlock_native_wallet_at_path(
|
|
handle.path.clone(),
|
|
std::option::Option::Some(&handle.alias),
|
|
std::option::Option::Some(handle),
|
|
password,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
/// Changes the protection password while preserving the exact keypair and public key.
|
|
pub async fn change_password(
|
|
&self,
|
|
alias: &crate::WalletAlias,
|
|
current_password: crate::WalletPassword,
|
|
new_password: crate::WalletPassword,
|
|
) -> ks_core::Result<crate::WalletFileHandle> {
|
|
let path = self.wallet_path(alias);
|
|
let container = match crate::read_native_wallet_container(&path).await {
|
|
std::result::Result::Ok(container) => container,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if container.alias() != alias {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_alias_mismatch",
|
|
"native wallet filename alias does not match the container alias",
|
|
));
|
|
}
|
|
let public_key = *container.public_key();
|
|
let keypair = match crate::unlock_native_wallet_keypair(container, current_password).await {
|
|
std::result::Result::Ok(keypair) => keypair,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let keypair_bytes = zeroize::Zeroizing::new(keypair.to_bytes());
|
|
let replacement = match crate::protect_native_wallet_keypair(
|
|
alias.clone(),
|
|
public_key,
|
|
keypair_bytes,
|
|
new_password,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(container) => container,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
match crate::replace_native_wallet_file_atomic(path.clone(), &replacement).await {
|
|
std::result::Result::Ok(()) => {},
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
}
|
|
tracing::info!(
|
|
target: crate::TRACING_TARGET,
|
|
action = "change_native_wallet_password",
|
|
wallet_alias = alias.as_str(),
|
|
public_key = %public_key,
|
|
"changed native wallet protection password"
|
|
);
|
|
return std::result::Result::Ok(crate::WalletFileHandle {
|
|
alias: alias.clone(),
|
|
public_key: public_key.to_string(),
|
|
format_version: crate::KSWALLET_FORMAT_VERSION,
|
|
path,
|
|
});
|
|
}
|
|
}
|
|
|
|
async fn unlock_native_wallet_at_path(
|
|
path: std::path::PathBuf,
|
|
expected_alias: std::option::Option<&crate::WalletAlias>,
|
|
expected_handle: std::option::Option<&crate::WalletFileHandle>,
|
|
password: crate::WalletPassword,
|
|
) -> ks_core::Result<crate::UnlockedWallet> {
|
|
let container = match crate::read_native_wallet_container(&path).await {
|
|
std::result::Result::Ok(container) => container,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if let std::option::Option::Some(alias) = expected_alias {
|
|
if container.alias() != alias {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_alias_mismatch",
|
|
"native wallet alias does not match the requested identity",
|
|
));
|
|
}
|
|
}
|
|
if let std::option::Option::Some(handle) = expected_handle {
|
|
if handle.format_version != crate::KSWALLET_FORMAT_VERSION
|
|
|| container.alias() != &handle.alias
|
|
|| container.public_key().to_string() != handle.public_key
|
|
{
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_handle_stale",
|
|
"native wallet file no longer matches the inspected handle",
|
|
));
|
|
}
|
|
}
|
|
let alias = container.alias().clone();
|
|
let public_key = *container.public_key();
|
|
let keypair = match crate::unlock_native_wallet_keypair(container, password).await {
|
|
std::result::Result::Ok(keypair) => keypair,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
tracing::debug!(
|
|
target: crate::TRACING_TARGET,
|
|
action = "unlock_native_wallet",
|
|
wallet_alias = alias.as_str(),
|
|
public_key = %public_key,
|
|
"authenticated native wallet"
|
|
);
|
|
return std::result::Result::Ok(crate::UnlockedWallet::new(alias, keypair));
|
|
}
|
|
|
|
fn has_native_wallet_extension(path: &std::path::Path) -> bool {
|
|
return path.extension()
|
|
== std::option::Option::Some(std::ffi::OsStr::new(crate::KSWALLET_FILE_EXTENSION));
|
|
}
|
|
|
|
async fn inspect_native_wallet_file(
|
|
path: std::path::PathBuf,
|
|
) -> ks_core::Result<crate::WalletFileHandle> {
|
|
if !has_native_wallet_extension(&path) {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_extension_invalid",
|
|
"native wallet file must use the .kswallet extension",
|
|
));
|
|
}
|
|
let stem = match path.file_stem().and_then(std::ffi::OsStr::to_str) {
|
|
std::option::Option::Some(stem) => stem,
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_alias_encoding_invalid",
|
|
"native wallet filename must contain a UTF-8 wallet alias",
|
|
));
|
|
},
|
|
};
|
|
let filename_alias = match crate::WalletAlias::parse(stem) {
|
|
std::result::Result::Ok(alias) => alias,
|
|
std::result::Result::Err(_) => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_alias_invalid",
|
|
"native wallet filename contains an invalid wallet alias",
|
|
));
|
|
},
|
|
};
|
|
match validate_native_wallet_file_metadata(&path).await {
|
|
std::result::Result::Ok(()) => {},
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
}
|
|
let container = match crate::read_native_wallet_container(&path).await {
|
|
std::result::Result::Ok(container) => container,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if container.alias() != &filename_alias {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_native_alias_mismatch",
|
|
"native wallet filename alias does not match the container alias",
|
|
));
|
|
}
|
|
return std::result::Result::Ok(crate::WalletFileHandle {
|
|
alias: filename_alias,
|
|
public_key: container.public_key().to_string(),
|
|
format_version: crate::KSWALLET_FORMAT_VERSION,
|
|
path,
|
|
});
|
|
}
|
|
|
|
async fn validate_wallet_directory(directory: &std::path::Path) -> ks_core::Result<()> {
|
|
let metadata = match tokio::fs::symlink_metadata(directory).await {
|
|
std::result::Result::Ok(metadata) => metadata,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_directory_metadata_failed",
|
|
error.to_string(),
|
|
));
|
|
},
|
|
};
|
|
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_directory_type_invalid",
|
|
"wallet directory must be a directory and not a symlink",
|
|
));
|
|
}
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
|
let mode = metadata.permissions().mode() & 0o777;
|
|
if mode & 0o077 != 0 {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_directory_permissions_too_open",
|
|
format!("wallet directory has mode {mode:o}; expected no group or other access"),
|
|
));
|
|
}
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
|
|
async fn validate_native_wallet_file_metadata(path: &std::path::Path) -> ks_core::Result<()> {
|
|
let metadata = match tokio::fs::symlink_metadata(path).await {
|
|
std::result::Result::Ok(metadata) => metadata,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_file_metadata_failed",
|
|
error.to_string(),
|
|
));
|
|
},
|
|
};
|
|
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_file_type_invalid",
|
|
"wallet file must be a regular file and not a symlink",
|
|
));
|
|
}
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
|
let mode = metadata.permissions().mode() & 0o777;
|
|
if mode & 0o077 != 0 {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"wallet_file_permissions_too_open",
|
|
format!("wallet file has mode {mode:o}; expected no group or other access"),
|
|
));
|
|
}
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
fn fixture_bytes(alias: &str, marker: u8) -> std::vec::Vec<u8> {
|
|
let parsed_alias = crate::WalletAlias::parse(alias)
|
|
.unwrap_or_else(|error| panic!("fixture alias must be valid: {error}"));
|
|
let alias_bytes = parsed_alias.as_str().as_bytes();
|
|
let header_length = crate::KSWALLET_FIXED_HEADER_LENGTH + alias_bytes.len();
|
|
let total_length = header_length
|
|
+ crate::KSWALLET_SALT_LENGTH
|
|
+ crate::KSWALLET_NONCE_LENGTH
|
|
+ crate::KSWALLET_CIPHERTEXT_LENGTH;
|
|
let mut bytes = vec![0_u8; total_length];
|
|
bytes[0..crate::KSWALLET_MAGIC.len()].copy_from_slice(crate::KSWALLET_MAGIC);
|
|
bytes[8..10].copy_from_slice(&crate::KSWALLET_FORMAT_VERSION.to_le_bytes());
|
|
bytes[10..12].copy_from_slice(&(header_length as u16).to_le_bytes());
|
|
bytes[12..14].copy_from_slice(&crate::KSWALLET_FLAGS_NONE.to_le_bytes());
|
|
bytes[14] = crate::KSWALLET_KDF_ARGON2ID;
|
|
bytes[15] = crate::KSWALLET_ARGON2_VERSION;
|
|
bytes[16] = crate::KSWALLET_AEAD_XCHACHA20_POLY1305;
|
|
bytes[17] = crate::KSWALLET_SALT_LENGTH as u8;
|
|
bytes[18] = crate::KSWALLET_NONCE_LENGTH as u8;
|
|
bytes[19] = alias_bytes.len() as u8;
|
|
bytes[24..28].copy_from_slice(&65_536_u32.to_le_bytes());
|
|
bytes[28..32].copy_from_slice(&3_u32.to_le_bytes());
|
|
bytes[32..36].copy_from_slice(&4_u32.to_le_bytes());
|
|
bytes[36..40].copy_from_slice(&(crate::KSWALLET_CIPHERTEXT_LENGTH as u32).to_le_bytes());
|
|
bytes[40..72].copy_from_slice(&[marker; 32]);
|
|
bytes[crate::KSWALLET_FIXED_HEADER_LENGTH..header_length].copy_from_slice(alias_bytes);
|
|
let salt_offset = header_length;
|
|
let nonce_offset = salt_offset + crate::KSWALLET_SALT_LENGTH;
|
|
let ciphertext_offset = nonce_offset + crate::KSWALLET_NONCE_LENGTH;
|
|
bytes[salt_offset..nonce_offset].fill(marker.wrapping_add(1));
|
|
bytes[nonce_offset..ciphertext_offset].fill(marker.wrapping_add(2));
|
|
bytes[ciphertext_offset..].fill(marker.wrapping_add(3));
|
|
return bytes;
|
|
}
|
|
|
|
fn write_native_wallet(path: &std::path::Path, alias: &str, marker: u8) {
|
|
let bytes = fixture_bytes(alias, marker);
|
|
std::fs::write(path, bytes)
|
|
.unwrap_or_else(|error| panic!("native fixture must be writable: {error}"));
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
|
|
.unwrap_or_else(|error| panic!("native fixture permissions must be set: {error}"));
|
|
}
|
|
}
|
|
|
|
fn make_directory_private(path: &std::path::Path) {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap_or_else(
|
|
|error| panic!("wallet fixture directory permissions must be set: {error}"),
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn scan_is_non_recursive_and_filters_by_native_extension() {
|
|
let directory = tempfile::tempdir()
|
|
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
|
make_directory_private(directory.path());
|
|
let manager = crate::WalletManager::new(directory.path())
|
|
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
|
write_native_wallet(&directory.path().join("alpha.kswallet"), "alpha", 3);
|
|
std::fs::write(directory.path().join("legacy.json"), b"[]")
|
|
.unwrap_or_else(|error| panic!("unrelated fixture must be writable: {error}"));
|
|
let nested = directory.path().join("nested");
|
|
std::fs::create_dir(&nested)
|
|
.unwrap_or_else(|error| panic!("nested directory must be created: {error}"));
|
|
write_native_wallet(&nested.join("hidden.kswallet"), "hidden", 5);
|
|
let wallets = manager
|
|
.scan()
|
|
.await
|
|
.unwrap_or_else(|error| panic!("scan must succeed: {error}"));
|
|
assert_eq!(wallets.len(), 1);
|
|
assert_eq!(wallets[0].alias().as_str(), "alpha");
|
|
assert_eq!(wallets[0].format_version(), 1);
|
|
assert_eq!(wallets[0].identity().persistence, crate::WalletPersistence::Persistent);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn lookup_uses_configured_directory_and_returns_opaque_handle() {
|
|
let directory = tempfile::tempdir()
|
|
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
|
let manager = crate::WalletManager::new(directory.path())
|
|
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
|
let alias = crate::WalletAlias::parse("lookup")
|
|
.unwrap_or_else(|error| panic!("unexpected alias error: {error}"));
|
|
write_native_wallet(&manager.wallet_path(&alias), "lookup", 7);
|
|
let handle = manager
|
|
.lookup(&alias)
|
|
.await
|
|
.unwrap_or_else(|error| panic!("lookup must succeed: {error}"))
|
|
.unwrap_or_else(|| panic!("wallet handle must exist"));
|
|
assert_eq!(handle.alias(), &alias);
|
|
assert_eq!(handle.path, manager.wallet_path(&alias));
|
|
assert_eq!(handle.public_key(), handle.identity().public_key);
|
|
assert!(!format!("{handle:?}").contains(directory.path().to_string_lossy().as_ref()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explicit_file_inspection_accepts_path_outside_configured_directory() {
|
|
let configured = tempfile::tempdir()
|
|
.unwrap_or_else(|error| panic!("configured directory must exist: {error}"));
|
|
make_directory_private(configured.path());
|
|
let external = tempfile::tempdir()
|
|
.unwrap_or_else(|error| panic!("external directory must exist: {error}"));
|
|
let manager = crate::WalletManager::new(configured.path())
|
|
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
|
let external_path = external.path().join("external.kswallet");
|
|
write_native_wallet(&external_path, "external", 9);
|
|
let handle = manager
|
|
.inspect_file(&external_path)
|
|
.await
|
|
.unwrap_or_else(|error| panic!("explicit inspection must succeed: {error}"));
|
|
assert_eq!(handle.alias().as_str(), "external");
|
|
assert_eq!(handle.path, external_path);
|
|
assert!(!handle.public_key().is_empty());
|
|
let discovered = manager
|
|
.scan()
|
|
.await
|
|
.unwrap_or_else(|error| panic!("configured scan must succeed: {error}"));
|
|
assert!(discovered.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prefix_only_native_file_is_rejected_by_complete_codec() {
|
|
let directory = tempfile::tempdir()
|
|
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
|
let path = directory.path().join("short.kswallet");
|
|
let mut bytes = std::vec::Vec::new();
|
|
bytes.extend_from_slice(crate::KSWALLET_MAGIC);
|
|
bytes.extend_from_slice(&crate::KSWALLET_FORMAT_VERSION.to_le_bytes());
|
|
std::fs::write(&path, bytes)
|
|
.unwrap_or_else(|error| panic!("fixture must be writable: {error}"));
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
|
.unwrap_or_else(|error| panic!("fixture permissions must be set: {error}"));
|
|
}
|
|
let manager = crate::WalletManager::new(directory.path())
|
|
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
|
let error = manager
|
|
.inspect_file(&path)
|
|
.await
|
|
.err()
|
|
.unwrap_or_else(|| panic!("prefix-only fixture must fail"));
|
|
assert_eq!(error.code(), "wallet_native_file_length_invalid");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn filename_and_header_alias_must_match() {
|
|
let directory = tempfile::tempdir()
|
|
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
|
let path = directory.path().join("filename.kswallet");
|
|
write_native_wallet(&path, "header", 11);
|
|
let manager = crate::WalletManager::new(directory.path())
|
|
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
|
let error = manager
|
|
.inspect_file(&path)
|
|
.await
|
|
.err()
|
|
.unwrap_or_else(|| panic!("alias mismatch must fail"));
|
|
assert_eq!(error.code(), "wallet_native_alias_mismatch");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn codec_errors_do_not_disclose_external_path() {
|
|
let configured = tempfile::tempdir()
|
|
.unwrap_or_else(|error| panic!("configured directory must exist: {error}"));
|
|
let external = tempfile::tempdir()
|
|
.unwrap_or_else(|error| panic!("external directory must exist: {error}"));
|
|
let manager = crate::WalletManager::new(configured.path())
|
|
.unwrap_or_else(|error| panic!("unexpected manager error: {error}"));
|
|
let path = external.path().join("invalid.kswallet");
|
|
write_native_wallet(&path, "invalid", 13);
|
|
let mut bytes = std::fs::read(&path)
|
|
.unwrap_or_else(|error| panic!("fixture must be readable: {error}"));
|
|
bytes[0] = b'X';
|
|
std::fs::write(&path, bytes)
|
|
.unwrap_or_else(|error| panic!("fixture must be rewritten: {error}"));
|
|
let error = manager
|
|
.inspect_file(&path)
|
|
.await
|
|
.err()
|
|
.unwrap_or_else(|| panic!("invalid native wallet must fail"));
|
|
assert_eq!(error.code(), "wallet_native_magic_invalid");
|
|
assert!(!error.to_string().contains(external.path().to_string_lossy().as_ref()));
|
|
}
|
|
}
|