From d4ac628c990a355c880c63229f7b5e8d026a8996 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sat, 25 Jul 2026 08:44:47 +0200 Subject: [PATCH] v0.1.0-pre.027 --- CHANGELOG.md | 12 +- README.md | 2 +- ROADMAP.md | 13 +- kb-wallet/Cargo.toml | 11 +- kb-wallet/README.md | 18 ++ kb-wallet/src/constants.rs | 9 + kb-wallet/src/lib.rs | 29 +- kb-wallet/src/wallet.rs | 545 +++++++++++++++++++++++++++++++++++++ 8 files changed, 619 insertions(+), 20 deletions(-) create mode 100644 kb-wallet/README.md create mode 100644 kb-wallet/src/constants.rs create mode 100644 kb-wallet/src/wallet.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9de3566..f4fe333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,13 @@ - + # CHANGELOG -## 0.1.0-pre.025 +## [0.1.0-pre.027] - 2026-07-25 -- Migration de la couche WebSocket standard de `kb-onchain-transport`. -- Portage des requêtes et notifications typées des neuf familles d’abonnements Solana. -- Portage du client WebSocket one-shot, du pool orienté rôles et des sessions persistantes avec reconnexion et restauration des abonnements. -- Conservation des 27 tests WebSocket de bot2 adaptés au nouveau nom de crate. -- `Cargo.lock` n’est pas livré dans les deltas ; seules les modifications de `Cargo.toml` sont incluses. +- Migration complète de `kb-wallet` depuis `kb_wallet` bot2. +- Conservation des six tests de validation des alias, signatures, persistance, non-écrasement, corruption et permissions Unix. +- Conservation du format JSON Solana standard, de la protection contre les symlinks et de l’effacement des buffers secrets. ## 0.1.0-pre.024 diff --git a/README.md b/README.md index 891825e..c149564 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Khadhroony Bot3 is the consolidated successor workspace to `khadhroony-bot2`. - `kb-pipeline`: ingestion, decoding, materialization and execution orchestration. - `kb-onchain-transport`: HTTP and WebSocket Solana transports. - `kb-store`: store-neutral contracts and PostgreSQL implementation modules. -- `kb-wallet`: wallet and signer support. +- `kb-wallet`: stockage local protégé de keypairs temporaires, résumés non secrets et signature de messages. - `kb-app-demo`: the only binary retained during the initial migration. ## Migration principle diff --git a/ROADMAP.md b/ROADMAP.md index cba4a01..0fc4121 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,5 @@ - + # ROADMAP — khadhroony-bot3 @@ -15,12 +15,12 @@ - [x] Porter les modèles de `kb_model` vers `kb-lib`. - [x] Porter les décodeurs Solana core, SPL et Metaplex déjà implémentés dans bot2. - [x] Porter leurs matérialisateurs déjà implémentés dans bot2. -- [x] Porter leurs exécuteurs. +- [ ] Porter leurs exécuteurs. - [x] Fusionner `kb_store_core` et `kb_store_pg` dans `kb-store`. - [ ] Migrer `kb-onchain-transport` depuis l’ancienne `kb_rpc`. - [x] Renommer structurellement la crate. - [x] Porter les contrats JSON-RPC, rôles d’endpoints, validation, clients/pools HTTP et méthodes HTTP standard. - - [x] Porter WebSocket, sessions et pools d’abonnements. + - [ ] Porter WebSocket, sessions et pools d’abonnements. - [ ] Porter l’acquisition canonique `getTransaction` et `getSignaturesForAddress`. - [ ] Porter simulation, envoi et confirmation réseau complets. - [ ] Adapter `kb-pipeline` aux nouveaux chemins publics. @@ -269,3 +269,10 @@ - [ ] Valider le renommage avec Cargo sur la machine de développement. - [ ] Migrer ensuite les contrats communs et le transport HTTP standard de l’ancienne `kb_rpc`. + +### 0.1.0-pre.027 — Migration de `kb-wallet` + +- [x] Porter les contrats de wallet temporaire et persistant depuis bot2. +- [x] Conserver les six tests bot2 sans réduction fonctionnelle. +- [x] Préserver les permissions privées, le rejet des symlinks et l’effacement des buffers secrets. +- [x] Maintenir la séparation entre signature locale et politique d’exécution. diff --git a/kb-wallet/Cargo.toml b/kb-wallet/Cargo.toml index 2f07557..6115858 100644 --- a/kb-wallet/Cargo.toml +++ b/kb-wallet/Cargo.toml @@ -1,5 +1,5 @@ # file: kb-wallet/Cargo.toml -# version: 1 +# version: 2 [package] name = "kb-wallet" @@ -10,6 +10,15 @@ publish.workspace = true [dependencies] kb-core = { path = "../kb-core" } +serde_json.workspace = true +solana-keypair.workspace = true +solana-signer.workspace = true +tokio.workspace = true +tracing.workspace = true +zeroize.workspace = true + +[dev-dependencies] +tempfile.workspace = true [lints] workspace = true diff --git a/kb-wallet/README.md b/kb-wallet/README.md new file mode 100644 index 0000000..7d37a60 --- /dev/null +++ b/kb-wallet/README.md @@ -0,0 +1,18 @@ + + + +# kb-wallet + +Frontière locale de portefeuille et de signature pour Khadhroony Bot3. + +La crate fournit : + +- des alias de portefeuille validés et non secrets ; +- des portefeuilles temporaires en mémoire ; +- un stockage JSON Solana persistant pour le développement et les tests d’intégration ; +- la signature de messages sans exposition des octets secrets ; +- des permissions Unix privées (`0700` pour le répertoire, `0600` pour les fichiers) ; +- le rejet des liens symboliques, fichiers non réguliers, permissions trop ouvertes et keypairs invalides ; +- l’effacement explicite des buffers contenant des octets secrets via `zeroize`. + +Cette crate ne décide pas si une transaction peut être envoyée. Les limites de dépense, la simulation et les politiques d’exécution restent dans `kb-lib` et les couches d’orchestration. diff --git a/kb-wallet/src/constants.rs b/kb-wallet/src/constants.rs new file mode 100644 index 0000000..e44dd07 --- /dev/null +++ b/kb-wallet/src/constants.rs @@ -0,0 +1,9 @@ +// file: kb-wallet/src/constants.rs +// version: 1 + +//! Local constants for the `kb-wallet` crate. + +/// Canonical tracing target for this crate. +pub(crate) const TRACING_TARGET: &str = "kb-wallet"; +/// Number of bytes stored by the standard Solana keypair JSON format. +pub(crate) const SOLANA_KEYPAIR_LENGTH: usize = 64; diff --git a/kb-wallet/src/lib.rs b/kb-wallet/src/lib.rs index a2ef429..54ceddd 100644 --- a/kb-wallet/src/lib.rs +++ b/kb-wallet/src/lib.rs @@ -1,13 +1,26 @@ // file: kb-wallet/src/lib.rs -// version: 1 +// version: 2 -#![forbid(unsafe_code)] -#![deny(unreachable_pub)] +//! Wallet boundary for local key storage and transaction signing. #![warn(missing_docs)] +#![deny(unreachable_pub)] +#![forbid(unsafe_code)] -//! Wallet contracts. +mod constants; +mod wallet; -/// Returns the crate migration status. -pub fn migration_status() -> &'static str { - return "scaffolded"; -} +/// Number of bytes stored by the standard Solana keypair JSON format. +pub(crate) use self::constants::SOLANA_KEYPAIR_LENGTH; +/// Canonical tracing target for this crate. +pub(crate) use self::constants::TRACING_TARGET; + +/// Solana keypair kept private inside the wallet boundary. +pub use self::wallet::TemporaryWallet; +/// Filesystem-backed store for development and integration-test wallets. +pub use self::wallet::TemporaryWalletStore; +/// Validated non-secret wallet alias. +pub use self::wallet::WalletAlias; +/// Non-secret wallet policy. +pub use self::wallet::WalletPolicy; +/// Non-secret wallet description. +pub use self::wallet::WalletSummary; diff --git a/kb-wallet/src/wallet.rs b/kb-wallet/src/wallet.rs new file mode 100644 index 0000000..6235619 --- /dev/null +++ b/kb-wallet/src/wallet.rs @@ -0,0 +1,545 @@ +// file: kb-wallet/src/wallet.rs +// version: 8 + +//! Local wallet storage and signing primitives. + +use solana_signer::Signer; // rust-rules: trait-import +use zeroize::Zeroize; // rust-rules: trait-import + +/// Validated non-secret wallet alias used as a local filename stem. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct WalletAlias(std::string::String); + +impl crate::WalletAlias { + /// Parses and validates a wallet alias. + pub fn parse(value: impl std::convert::Into) -> kb_core::Result { + let value = value.into(); + if value.is_empty() || value.len() > 64 { + return std::result::Result::Err(kb_core::Error::new( + "wallet_alias_length_invalid", + "wallet alias length must be between 1 and 64 bytes", + )); + } + if !value.bytes().enumerate().all(|(index, byte)| { + return byte.is_ascii_alphanumeric() || (index > 0 && (byte == b'_' || byte == b'-')); + }) { + return std::result::Result::Err(kb_core::Error::new( + "wallet_alias_invalid", + "wallet alias must start with an ASCII letter or digit and contain only ASCII letters, digits, '_' or '-'", + )); + } + return std::result::Result::Ok(Self(value)); + } + + /// Returns the validated alias text. + pub fn as_str(&self) -> &str { + return self.0.as_str(); + } +} + +/// Non-secret wallet policy used by callers before requesting a signature. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WalletPolicy { + /// Whether signing is enabled for this wallet. + pub signing_enabled: bool, + /// Optional lamport spend limit enforced by the execution layer. + pub lamport_spend_limit: std::option::Option, +} + +/// Non-secret description of a loaded wallet. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WalletSummary { + /// Validated local alias. + pub alias: crate::WalletAlias, + /// Base58 Solana public key. + pub public_key: std::string::String, + /// Persistent keypair path when the wallet was loaded from local storage. + pub storage_path: std::option::Option, +} + +/// Solana keypair kept private inside the wallet boundary. +pub struct TemporaryWallet { + alias: crate::WalletAlias, + keypair: solana_keypair::Keypair, + storage_path: std::option::Option, +} + +impl std::fmt::Debug for crate::TemporaryWallet { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter + .debug_struct("TemporaryWallet") + .field("alias", &self.alias) + .field("public_key", &self.public_key()) + .field("storage_path", &self.storage_path) + .finish(); + } +} + +impl crate::TemporaryWallet { + /// Generates a non-persistent wallet in memory. + pub fn generate(alias: crate::WalletAlias) -> Self { + return Self { + alias, + keypair: solana_keypair::Keypair::new(), + storage_path: std::option::Option::None, + }; + } + + /// Returns a non-secret summary suitable for logs, CLI output or application adapters. + pub fn summary(&self) -> crate::WalletSummary { + return crate::WalletSummary { + alias: self.alias.clone(), + public_key: self.public_key(), + storage_path: self.storage_path.clone(), + }; + } + + /// Returns the wallet public key in base58 form. + pub fn public_key(&self) -> std::string::String { + return self.keypair.pubkey().to_string(); + } + + /// Returns the signer interface without exposing keypair bytes. + pub fn as_signer(&self) -> &dyn solana_signer::Signer { + return &self.keypair; + } + + /// Signs arbitrary message bytes and returns the base58 signature. + pub fn sign_message(&self, message: &[u8]) -> kb_core::Result { + let signature = match self.keypair.try_sign_message(message) { + std::result::Result::Ok(signature) => signature, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::new( + "wallet_message_sign_failed", + error.to_string(), + )); + }, + }; + tracing::debug!( + target: crate::TRACING_TARGET, + action = "sign_message", + wallet_alias = self.alias.as_str(), + public_key = %self.keypair.pubkey(), + message_length = message.len(), + "signed message with local wallet" + ); + return std::result::Result::Ok(signature.to_string()); + } +} + +/// Filesystem-backed store for development and integration-test wallets. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TemporaryWalletStore { + directory: std::path::PathBuf, +} + +impl crate::TemporaryWalletStore { + /// Creates a wallet store rooted at the supplied directory. + pub fn new(directory: impl std::convert::Into) -> kb_core::Result { + let directory = directory.into(); + if directory.as_os_str().is_empty() { + return std::result::Result::Err(kb_core::Error::new( + "wallet_directory_empty", + "wallet directory must not be empty", + )); + } + return std::result::Result::Ok(Self { directory }); + } + + /// Returns the configured wallet directory. + pub fn directory(&self) -> &std::path::Path { + return self.directory.as_path(); + } + + /// Returns the deterministic JSON path for an alias. + pub fn wallet_path(&self, alias: &crate::WalletAlias) -> std::path::PathBuf { + return self.directory.join(format!("{}.json", alias.as_str())); + } + + /// Returns whether a persistent wallet exists for an alias. + pub async fn exists(&self, alias: &crate::WalletAlias) -> kb_core::Result { + let path = self.wallet_path(alias); + return match tokio::fs::try_exists(&path).await { + std::result::Result::Ok(exists) => std::result::Result::Ok(exists), + std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new( + "wallet_file_exists_check_failed", + format!("{}: {error}", path.display()), + )), + }; + } + + /// Creates and persists a new wallet without overwriting an existing file. + pub async fn create( + &self, + alias: crate::WalletAlias, + ) -> kb_core::Result { + match prepare_wallet_directory(&self.directory).await { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + let path = self.wallet_path(&alias); + let keypair = solana_keypair::Keypair::new(); + match write_new_keypair(path.clone(), &keypair).await { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + tracing::info!( + target: crate::TRACING_TARGET, + action = "create_temporary_wallet", + wallet_alias = alias.as_str(), + public_key = %keypair.pubkey(), + wallet_path = %path.display(), + "created persistent temporary wallet" + ); + return std::result::Result::Ok(crate::TemporaryWallet { + alias, + keypair, + storage_path: std::option::Option::Some(path), + }); + } + + /// Loads and validates an existing persistent wallet. + pub async fn load(&self, alias: crate::WalletAlias) -> kb_core::Result { + let path = self.wallet_path(&alias); + let keypair = match read_keypair(&path).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 = "load_temporary_wallet", + wallet_alias = alias.as_str(), + public_key = %keypair.pubkey(), + wallet_path = %path.display(), + "loaded persistent temporary wallet" + ); + return std::result::Result::Ok(crate::TemporaryWallet { + alias, + keypair, + storage_path: std::option::Option::Some(path), + }); + } + + /// Loads an existing wallet or creates it when absent. + pub async fn load_or_create( + &self, + alias: crate::WalletAlias, + ) -> kb_core::Result { + let exists = match self.exists(&alias).await { + std::result::Result::Ok(exists) => exists, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if exists { + return self.load(alias).await; + } + let create_result = self.create(alias.clone()).await; + return match create_result { + std::result::Result::Ok(wallet) => std::result::Result::Ok(wallet), + std::result::Result::Err(error) if error.code() == "wallet_file_already_exists" => { + return self.load(alias).await; + }, + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } +} + +async fn prepare_wallet_directory(directory: &std::path::Path) -> kb_core::Result<()> { + match tokio::fs::create_dir_all(directory).await { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::new( + "wallet_directory_create_failed", + format!("{}: {error}", directory.display()), + )); + }, + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import + let permissions = std::fs::Permissions::from_mode(0o700); + match tokio::fs::set_permissions(directory, permissions).await { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::new( + "wallet_directory_permissions_failed", + format!("{}: {error}", directory.display()), + )); + }, + } + } + return std::result::Result::Ok(()); +} + +async fn write_new_keypair( + path: std::path::PathBuf, + keypair: &solana_keypair::Keypair, +) -> kb_core::Result<()> { + let mut keypair_bytes = keypair.to_bytes(); + let mut encoded = match serde_json::to_vec(keypair_bytes.as_slice()) { + std::result::Result::Ok(encoded) => encoded, + std::result::Result::Err(error) => { + keypair_bytes.zeroize(); + return std::result::Result::Err(kb_core::Error::new( + "wallet_keypair_serialize_failed", + error.to_string(), + )); + }, + }; + keypair_bytes.zeroize(); + let task_path = path.clone(); + let task_result = tokio::task::spawn_blocking(move || { + use std::io::Write; // rust-rules: trait-import + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; // rust-rules: trait-import + options.mode(0o600); + } + let mut file = match options.open(&task_path) { + std::result::Result::Ok(file) => file, + std::result::Result::Err(error) => { + encoded.zeroize(); + let code = if error.kind() == std::io::ErrorKind::AlreadyExists { + "wallet_file_already_exists" + } else { + "wallet_file_create_failed" + }; + return std::result::Result::Err(kb_core::Error::new( + code, + format!("{}: {error}", task_path.display()), + )); + }, + }; + if let std::result::Result::Err(error) = file.write_all(encoded.as_slice()) { + encoded.zeroize(); + let _ = std::fs::remove_file(&task_path); + return std::result::Result::Err(kb_core::Error::new( + "wallet_file_write_failed", + format!("{}: {error}", task_path.display()), + )); + } + encoded.zeroize(); + if let std::result::Result::Err(error) = file.sync_all() { + let _ = std::fs::remove_file(&task_path); + return std::result::Result::Err(kb_core::Error::new( + "wallet_file_sync_failed", + format!("{}: {error}", task_path.display()), + )); + } + return std::result::Result::Ok(()); + }) + .await; + return match task_result { + std::result::Result::Ok(result) => result, + std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new( + "wallet_file_task_failed", + format!("{}: {error}", path.display()), + )), + }; +} + +async fn read_keypair(path: &std::path::Path) -> kb_core::Result { + match validate_wallet_file_metadata(path).await { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + let mut encoded = match tokio::fs::read(path).await { + std::result::Result::Ok(encoded) => encoded, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::new( + "wallet_file_read_failed", + format!("{}: {error}", path.display()), + )); + }, + }; + let parsed_result = serde_json::from_slice::>(encoded.as_slice()); + encoded.zeroize(); + let mut keypair_bytes = match parsed_result { + std::result::Result::Ok(keypair_bytes) => keypair_bytes, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::new( + "wallet_keypair_json_invalid", + format!("{}: {error}", path.display()), + )); + }, + }; + if keypair_bytes.len() != crate::SOLANA_KEYPAIR_LENGTH { + let length = keypair_bytes.len(); + keypair_bytes.zeroize(); + return std::result::Result::Err(kb_core::Error::new( + "wallet_keypair_length_invalid", + format!( + "{} contains {length} bytes instead of {}", + path.display(), + crate::SOLANA_KEYPAIR_LENGTH + ), + )); + } + let keypair_result = solana_keypair::Keypair::try_from(keypair_bytes.as_slice()); + keypair_bytes.zeroize(); + return match keypair_result { + std::result::Result::Ok(keypair) => std::result::Result::Ok(keypair), + std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new( + "wallet_keypair_invalid", + format!("{}: {error}", path.display()), + )), + }; +} + +async fn validate_wallet_file_metadata(path: &std::path::Path) -> kb_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(kb_core::Error::new( + "wallet_file_metadata_failed", + format!("{}: {error}", path.display()), + )); + }, + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return std::result::Result::Err(kb_core::Error::new( + "wallet_file_type_invalid", + format!("{} must be a regular file and not a symlink", path.display()), + )); + } + #[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(kb_core::Error::new( + "wallet_file_permissions_too_open", + format!("{} has mode {mode:o}; expected no group or other access", path.display()), + )); + } + } + return std::result::Result::Ok(()); +} + +#[cfg(test)] +mod tests { + #[test] + fn wallet_alias_rejects_path_traversal_and_invalid_characters() { + for value in ["", ".hidden", "../wallet", "wallet/name", "wallet name"] { + assert!(crate::WalletAlias::parse(value).is_err()); + } + assert!(crate::WalletAlias::parse("devnet-operator_01").is_ok()); + } + + #[test] + fn ephemeral_wallet_signs_without_exposing_secret_bytes() { + let alias = crate::WalletAlias::parse("ephemeral") + .unwrap_or_else(|error| panic!("unexpected alias error: {error}")); + let wallet = crate::TemporaryWallet::generate(alias); + let signature = wallet + .sign_message(b"khadhroony-wallet-test") + .unwrap_or_else(|error| panic!("unexpected signing error: {error}")); + assert!(!signature.is_empty()); + assert_eq!(wallet.public_key(), wallet.as_signer().pubkey().to_string()); + assert!(wallet.summary().storage_path.is_none()); + assert!(!format!("{wallet:?}").contains("secret")); + } + + #[tokio::test] + async fn persistent_wallet_roundtrips_and_preserves_public_key() { + let directory = tempfile::tempdir() + .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); + let store = crate::TemporaryWalletStore::new(directory.path().join("wallets")) + .unwrap_or_else(|error| panic!("unexpected store error: {error}")); + let alias = crate::WalletAlias::parse("integration-wallet") + .unwrap_or_else(|error| panic!("unexpected alias error: {error}")); + let created = store + .create(alias.clone()) + .await + .unwrap_or_else(|error| panic!("unexpected create error: {error}")); + let loaded = store + .load(alias.clone()) + .await + .unwrap_or_else(|error| panic!("unexpected load error: {error}")); + assert_eq!(created.public_key(), loaded.public_key()); + assert_eq!( + created.summary().storage_path, + std::option::Option::Some(store.wallet_path(&alias)) + ); + let encoded = std::fs::read(store.wallet_path(&alias)) + .unwrap_or_else(|error| panic!("wallet file must be readable: {error}")); + let keypair_bytes = serde_json::from_slice::>(encoded.as_slice()) + .unwrap_or_else(|error| panic!("wallet file must be standard JSON: {error}")); + assert_eq!(keypair_bytes.len(), crate::SOLANA_KEYPAIR_LENGTH); + } + + #[tokio::test] + async fn persistent_wallet_is_never_overwritten() { + let directory = tempfile::tempdir() + .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); + let store = crate::TemporaryWalletStore::new(directory.path()) + .unwrap_or_else(|error| panic!("unexpected store error: {error}")); + let alias = crate::WalletAlias::parse("existing") + .unwrap_or_else(|error| panic!("unexpected alias error: {error}")); + let first = store + .create(alias.clone()) + .await + .unwrap_or_else(|error| panic!("unexpected create error: {error}")); + let second = store.create(alias.clone()).await; + assert!(second.is_err()); + let loaded = store + .load_or_create(alias) + .await + .unwrap_or_else(|error| panic!("unexpected load-or-create error: {error}")); + assert_eq!(first.public_key(), loaded.public_key()); + } + + #[tokio::test] + async fn corrupted_wallet_file_is_rejected() { + let directory = tempfile::tempdir() + .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); + let store = crate::TemporaryWalletStore::new(directory.path()) + .unwrap_or_else(|error| panic!("unexpected store error: {error}")); + let alias = crate::WalletAlias::parse("corrupted") + .unwrap_or_else(|error| panic!("unexpected alias error: {error}")); + store + .create(alias.clone()) + .await + .unwrap_or_else(|error| panic!("fixture wallet must be created: {error}")); + std::fs::write(store.wallet_path(&alias), b"[1,2,3]") + .unwrap_or_else(|error| panic!("fixture must be writable: {error}")); + let result = store.load(alias).await; + assert!(result.is_err()); + assert_eq!( + result + .err() + .unwrap_or_else(|| panic!("corrupted wallet must return an error")) + .code(), + "wallet_keypair_length_invalid" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn persistent_wallet_uses_private_unix_permissions() { + use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import + let directory = tempfile::tempdir() + .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); + let store = crate::TemporaryWalletStore::new(directory.path().join("private-wallets")) + .unwrap_or_else(|error| panic!("unexpected store error: {error}")); + let alias = crate::WalletAlias::parse("permissions") + .unwrap_or_else(|error| panic!("unexpected alias error: {error}")); + store + .create(alias.clone()) + .await + .unwrap_or_else(|error| panic!("unexpected create error: {error}")); + let directory_mode = std::fs::metadata(store.directory()) + .unwrap_or_else(|error| panic!("directory metadata must exist: {error}")) + .permissions() + .mode() + & 0o777; + let file_mode = std::fs::metadata(store.wallet_path(&alias)) + .unwrap_or_else(|error| panic!("file metadata must exist: {error}")) + .permissions() + .mode() + & 0o777; + assert_eq!(directory_mode, 0o700); + assert_eq!(file_mode, 0o600); + } +}