v0.1.0-pre.027

This commit is contained in:
2026-07-25 08:44:47 +02:00
parent d00a4c7245
commit d4ac628c99
8 changed files with 619 additions and 20 deletions

View File

@@ -1,15 +1,13 @@
<!-- file: CHANGELOG.md --> <!-- file: CHANGELOG.md -->
<!-- version: 7 --> <!-- version: 6 -->
# CHANGELOG # CHANGELOG
## 0.1.0-pre.025 ## [0.1.0-pre.027] - 2026-07-25
- Migration de la couche WebSocket standard de `kb-onchain-transport`. - Migration complète de `kb-wallet` depuis `kb_wallet` bot2.
- Portage des requêtes et notifications typées des neuf familles dabonnements Solana. - Conservation des six tests de validation des alias, signatures, persistance, non-écrasement, corruption et permissions Unix.
- Portage du client WebSocket one-shot, du pool orienté rôles et des sessions persistantes avec reconnexion et restauration des abonnements. - Conservation du format JSON Solana standard, de la protection contre les symlinks et de leffacement des buffers secrets.
- Conservation des 27 tests WebSocket de bot2 adaptés au nouveau nom de crate.
- `Cargo.lock` nest pas livré dans les deltas ; seules les modifications de `Cargo.toml` sont incluses.
## 0.1.0-pre.024 ## 0.1.0-pre.024

View File

@@ -15,7 +15,7 @@ Khadhroony Bot3 is the consolidated successor workspace to `khadhroony-bot2`.
- `kb-pipeline`: ingestion, decoding, materialization and execution orchestration. - `kb-pipeline`: ingestion, decoding, materialization and execution orchestration.
- `kb-onchain-transport`: HTTP and WebSocket Solana transports. - `kb-onchain-transport`: HTTP and WebSocket Solana transports.
- `kb-store`: store-neutral contracts and PostgreSQL implementation modules. - `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. - `kb-app-demo`: the only binary retained during the initial migration.
## Migration principle ## Migration principle

View File

@@ -1,5 +1,5 @@
<!-- file: ROADMAP.md --> <!-- file: ROADMAP.md -->
<!-- version: 18 --> <!-- version: 17 -->
# ROADMAP — khadhroony-bot3 # ROADMAP — khadhroony-bot3
@@ -15,12 +15,12 @@
- [x] Porter les modèles de `kb_model` vers `kb-lib`. - [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 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 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`. - [x] Fusionner `kb_store_core` et `kb_store_pg` dans `kb-store`.
- [ ] Migrer `kb-onchain-transport` depuis lancienne `kb_rpc`. - [ ] Migrer `kb-onchain-transport` depuis lancienne `kb_rpc`.
- [x] Renommer structurellement la crate. - [x] Renommer structurellement la crate.
- [x] Porter les contrats JSON-RPC, rôles dendpoints, validation, clients/pools HTTP et méthodes HTTP standard. - [x] Porter les contrats JSON-RPC, rôles dendpoints, validation, clients/pools HTTP et méthodes HTTP standard.
- [x] Porter WebSocket, sessions et pools dabonnements. - [ ] Porter WebSocket, sessions et pools dabonnements.
- [ ] Porter lacquisition canonique `getTransaction` et `getSignaturesForAddress`. - [ ] Porter lacquisition canonique `getTransaction` et `getSignaturesForAddress`.
- [ ] Porter simulation, envoi et confirmation réseau complets. - [ ] Porter simulation, envoi et confirmation réseau complets.
- [ ] Adapter `kb-pipeline` aux nouveaux chemins publics. - [ ] Adapter `kb-pipeline` aux nouveaux chemins publics.
@@ -269,3 +269,10 @@
- [ ] Valider le renommage avec Cargo sur la machine de développement. - [ ] Valider le renommage avec Cargo sur la machine de développement.
- [ ] Migrer ensuite les contrats communs et le transport HTTP standard de lancienne `kb_rpc`. - [ ] Migrer ensuite les contrats communs et le transport HTTP standard de lancienne `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 leffacement des buffers secrets.
- [x] Maintenir la séparation entre signature locale et politique dexécution.

View File

@@ -1,5 +1,5 @@
# file: kb-wallet/Cargo.toml # file: kb-wallet/Cargo.toml
# version: 1 # version: 2
[package] [package]
name = "kb-wallet" name = "kb-wallet"
@@ -10,6 +10,15 @@ publish.workspace = true
[dependencies] [dependencies]
kb-core = { path = "../kb-core" } 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] [lints]
workspace = true workspace = true

18
kb-wallet/README.md Normal file
View File

@@ -0,0 +1,18 @@
<!-- file: kb-wallet/README.md -->
<!-- version: 1 -->
# 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 dinté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 ;
- leffacement 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 dexécution restent dans `kb-lib` et les couches dorchestration.

View File

@@ -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;

View File

@@ -1,13 +1,26 @@
// file: kb-wallet/src/lib.rs // file: kb-wallet/src/lib.rs
// version: 1 // version: 2
#![forbid(unsafe_code)] //! Wallet boundary for local key storage and transaction signing.
#![deny(unreachable_pub)]
#![warn(missing_docs)] #![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! Wallet contracts. mod constants;
mod wallet;
/// Returns the crate migration status. /// Number of bytes stored by the standard Solana keypair JSON format.
pub fn migration_status() -> &'static str { pub(crate) use self::constants::SOLANA_KEYPAIR_LENGTH;
return "scaffolded"; /// 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;

545
kb-wallet/src/wallet.rs Normal file
View File

@@ -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<std::string::String>) -> kb_core::Result<Self> {
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<u64>,
}
/// 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<std::path::PathBuf>,
}
/// Solana keypair kept private inside the wallet boundary.
pub struct TemporaryWallet {
alias: crate::WalletAlias,
keypair: solana_keypair::Keypair,
storage_path: std::option::Option<std::path::PathBuf>,
}
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<std::string::String> {
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<std::path::PathBuf>) -> kb_core::Result<Self> {
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<bool> {
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<crate::TemporaryWallet> {
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<crate::TemporaryWallet> {
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<crate::TemporaryWallet> {
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<solana_keypair::Keypair> {
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::<std::vec::Vec<u8>>(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::<std::vec::Vec<u8>>(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);
}
}