From eaa24e6f1106fdbcb4e27846fc4b66421eb0b7b0 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Mon, 10 Aug 2026 15:06:38 +0200 Subject: [PATCH] v0.5.2-pre.002 --- Cargo.toml | 4 +- .../V0_5_2_KS_WALLET_RESTRUCTURING_PLAN.md | 17 +- ks-wallet/CHANGELOG.md | 15 +- ks-wallet/README.md | 15 +- ks-wallet/TODO.md | 14 +- ks-wallet/USAGE.md | 76 +++- ks-wallet/src/constants.rs | 10 +- ks-wallet/src/lib.rs | 41 +- ks-wallet/src/manager.rs | 415 ++++++++++++++++++ ks-wallet/src/wallet.rs | 54 ++- ks-wallet/tests/legacy_characterization.rs | 184 ++++++++ ks-wallet/tests/public_api.rs | 48 ++ 12 files changed, 835 insertions(+), 58 deletions(-) create mode 100644 ks-wallet/src/manager.rs create mode 100644 ks-wallet/tests/legacy_characterization.rs create mode 100644 ks-wallet/tests/public_api.rs diff --git a/Cargo.toml b/Cargo.toml index 96a9617..4c34b65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ # file: Cargo.toml -# version: 56 +# version: 57 [workspace] resolver = "3" @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.5.2-pre.1" +version = "0.5.2-pre.2" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-bot3" diff --git a/docs/plans/V0_5_2_KS_WALLET_RESTRUCTURING_PLAN.md b/docs/plans/V0_5_2_KS_WALLET_RESTRUCTURING_PLAN.md index 7b3808d..5ab6ae0 100644 --- a/docs/plans/V0_5_2_KS_WALLET_RESTRUCTURING_PLAN.md +++ b/docs/plans/V0_5_2_KS_WALLET_RESTRUCTURING_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.5.2` — restructuration de `ks-wallet` @@ -40,6 +40,8 @@ Le store doit donc pouvoir scanner son répertoire résolu à la recherche des f Le scan doit être borné au répertoire possédé par le store, non récursif par défaut, refuser les symlinks/fichiers non réguliers et ne considérer un candidat comme wallet qu'après validation de l'alias, du suffixe, du magic et de la version du conteneur. Le nom de fichier n'est pas à lui seul une preuve de validité. +Cette découverte automatique n'interdit pas l'ouverture explicite d'un fichier natif situé ailleurs. Un consommateur, par exemple `kb-app-demo-desktop` après sélection via un file browser, doit pouvoir fournir un chemin arbitraire vers un `.kswallet` à `ks-wallet`. Ce chemin explicite ne modifie pas le répertoire configuré, n'enregistre pas automatiquement le fichier dans le store et ne doit pas être projeté dans une identité publique ou un DTO frontend. L'ouverture réelle utilisera le mot de passe fourni au moment de l'appel ou dans l'étape d'ouverture immédiatement associée. + ### 2.2 Wallets temporaires ou jetables `ks-wallet` doit conserver la capacité actuelle de générer des wallets temporaires en mémoire pour : @@ -469,11 +471,15 @@ Le découpage reste borné mais peut être ajusté si une tranche devient trop l ### `0.5.2-pre.002` — caractérisation exécutable et API de base - tests externes du legacy ; -- identité publique minimale ; -- manager/scan/listing/lookup multi-wallet ; +- identité publique minimale sans chemin local ; +- manager/scan/listing/lookup multi-wallet sur le répertoire configuré ; +- inspection explicite d'un `.kswallet` hors store sans mutation de la configuration ni auto-enregistrement ; +- préambule d'identification natif minimal `magic + version`, uniquement pour rendre la découverte exécutable ; - contrat de wallet temporaire ; - frontière de signature compatible avec `ks-lib`. +Le préambule d'identification introduit ici ne définit pas encore le payload protégé : le codec complet, les bornes du conteneur, KDF et AEAD restent dans `pre.003`. + ### `0.5.2-pre.003` — codec et stockage `.kswallet` - spécification binaire versionnée ; @@ -581,6 +587,7 @@ Le plan est désormais centré sur les décisions suivantes : 14. le format keypair JSON des binaires Solana est obligatoire en import et en export dans `0.5.2` ; 15. une matrice officielle des formats Phantom/Solflare/Backpack/Trust/Coinbase-Base et autres cibles pertinentes sera finalisée avant les adaptateurs ; 16. un seul adaptateur wallet tiers est implémenté dans `0.5.2` comme exemple, les autres formats faisables étant reportés au TODO sans version déterminée ; -17. les détails KDF/AEAD et le modèle runtime d'un wallet ouvert seront décidés dans leurs tranches techniques, après tests et vérification des dépendances. +17. les détails KDF/AEAD et le modèle runtime d'un wallet ouvert seront décidés dans leurs tranches techniques, après tests et vérification des dépendances ; +18. le scan automatique reste limité au répertoire configuré, tandis qu'un consommateur peut fournir explicitement un autre chemin `.kswallet` à `ks-wallet` sans modifier le store ni enregistrer ce fichier automatiquement. -Ces décisions suffisent pour ouvrir `0.5.2-pre.002` après validation du présent fix de `pre.001`. +Ces décisions ont permis d'ouvrir `0.5.2-pre.002`. diff --git a/ks-wallet/CHANGELOG.md b/ks-wallet/CHANGELOG.md index f3645a7..5c14c23 100644 --- a/ks-wallet/CHANGELOG.md +++ b/ks-wallet/CHANGELOG.md @@ -1,8 +1,21 @@ - + # CHANGELOG — ks-wallet +## `0.5.2-pre.002` + +- ajoute `WalletManager` pour la découverte non récursive des `.kswallet` dans le répertoire configuré et le lookup par alias ; +- ajoute `WalletManager::inspect_file()` pour inspecter explicitement un `.kswallet` situé hors du store sans modifier la configuration ni l'enregistrer automatiquement ; +- ajoute `WalletFileHandle`, handle opaque dont le chemin local reste privé et absent de `Debug` ; +- introduit le préambule d'identification natif minimal `KSWALLET` + version `u16` little-endian `1`, sans définir encore le payload, KDF ou AEAD ; +- ajoute `WalletIdentity` et `WalletPersistence`, et transforme `WalletSummary` en alias de compatibilité vers l'identité non sensible ; +- retire le chemin de stockage de `WalletSummary` et du `Debug` de `TemporaryWallet` ; +- conserve les wallets temporaires et la frontière `solana_signer::Signer` existante ; +- externalise la caractérisation du legacy dans `tests/legacy_characterization.rs` avec fixture synthétique, erreurs, permissions, symlink et refus d'écrasement ; +- ajoute `tests/public_api.rs` pour figer la façade crate-root multi-wallet et l'absence de chemin dans le `Debug` du handle ; +- ne crée encore aucun `.kswallet` réel et n'introduit aucun mot de passe ou chiffrement persistant. + ## `0.5.2-pre.001` - caractérise précisément le format legacy `.json`, ses permissions Unix, ses erreurs, sa publication directe dans le chemin final et ses limites d’atomicité/TOCTOU ; diff --git a/ks-wallet/README.md b/ks-wallet/README.md index 22eb7a7..d1fd3d0 100644 --- a/ks-wallet/README.md +++ b/ks-wallet/README.md @@ -1,5 +1,5 @@ - + # ks-wallet @@ -7,15 +7,18 @@ ## État actuel -En `0.5.2-pre.001`, la crate reste une ébauche fonctionnelle basée sur le format legacy Solana JSON. La restructuration `0.5.2` est encore au stade plan/caractérisation et ne modifie pas le runtime. +En `0.5.2-pre.002`, la crate possède désormais la première API multi-wallet sans encore créer ni déchiffrer de conteneur natif persistant. Le legacy Solana JSON reste utilisé par les consommateurs historiques pendant la transition. La crate fournit actuellement : - alias validés ; +- identité publique minimale `WalletIdentity` sans chemin local ; +- `WalletManager` pour scanner/lookup les `.kswallet` du répertoire configuré ; +- inspection explicite d'un `.kswallet` sélectionné hors du store via un handle opaque ; - wallet temporaire en mémoire ; -- stockage local JSON d'un keypair Solana ; +- stockage legacy local JSON d'un keypair Solana ; - création exclusive avec refus d'écrasement et reprise des courses `load_or_create` ; -- résumé sans matériau cryptographique secret, mais contenant encore un chemin local interne ; +- résumé/identité sans matériau cryptographique secret ni chemin local ; - accès au trait `Signer` sans exposition des octets ; - signature de messages ; - permissions Unix privées et contrôles de fichiers ; @@ -39,7 +42,9 @@ La persistance legacy écrit encore directement le contenu dans le chemin final - importer et exporter obligatoirement le format keypair JSON des binaires Solana ; - documenter les formats compatibles des principaux wallets Solana, implémenter un adaptateur tiers d'exemple et reporter les autres au TODO. -Le format `.kswallet` sera propre à `ks-wallet`, mais sa protection cryptographique utilisera des primitives standards. Changer le mot de passe ne change jamais la keypair : une modification réelle du secret Ed25519 produirait une autre pubkey et donc un autre wallet. Le détail du KDF, de l'AEAD et du layout binaire sera fixé dans les tranches techniques après validation du plan et des dépendances réellement résolues. +Le format `.kswallet` sera propre à `ks-wallet`, mais sa protection cryptographique utilisera des primitives standards. `pre.002` ne fixe que son préambule d'identification minimal (`KSWALLET` + version `1`) afin de rendre scan et lookup exécutables ; le payload protégé n'existe pas encore. Changer le mot de passe ne change jamais la keypair : une modification réelle du secret Ed25519 produirait une autre pubkey et donc un autre wallet. Le détail du KDF, de l'AEAD et du reste du layout binaire sera fixé dans les tranches techniques après validation des dépendances réellement résolues. + +Le scan automatique reste strictement borné au répertoire fourni à `WalletManager`. Un programme peut néanmoins demander l'inspection d'un autre fichier `.kswallet` choisi explicitement, par exemple via un file browser desktop ; cette opération ne modifie pas le store et ne rend pas le chemin public. ## Relations diff --git a/ks-wallet/TODO.md b/ks-wallet/TODO.md index 616d23d..9edd434 100644 --- a/ks-wallet/TODO.md +++ b/ks-wallet/TODO.md @@ -1,19 +1,21 @@ - + # TODO — ks-wallet ## `0.5.2` -- [ ] caractériser par tests externes le format legacy `.json`, ses erreurs et ses permissions. -- [ ] définir l'identité publique minimale d'un wallet et le lookup par alias. -- [ ] gérer plusieurs wallets persistants sans wallet actif global mutable et les découvrir par scan borné des `.kswallet` du store résolu. -- [ ] conserver/généraliser les wallets temporaires ou jetables purement en mémoire. +- [x] caractériser par tests externes le format legacy `.json`, ses erreurs et ses permissions. +- [x] définir l'identité publique minimale d'un wallet et le lookup par alias. +- [x] introduire le manager, le scan borné et le lookup des `.kswallet` identifiables dans le store résolu ; renforcer la validation avec le codec complet en `pre.003`. +- [x] conserver les wallets temporaires ou jetables purement en mémoire et leur frontière de signature actuelle. +- [x] permettre l'inspection explicite d'un `.kswallet` hors store sans mutation de la configuration ni exposition publique du chemin. - [ ] spécifier puis implémenter le format natif binaire versionné `.kswallet`. - [ ] sélectionner et documenter KDF/AEAD/paramètres après vérification des dépendances résolues. - [ ] créer/ouvrir un wallet persistant avec mot de passe sans exposer les bytes privés. - [ ] permettre le changement de mot de passe en rechiffrant exactement la même keypair et donc en conservant la même pubkey. -- [ ] fournir une capacité de signature compatible avec les consommateurs sans dépendance de `ks-lib` vers `ks-wallet`. +- [x] préserver la frontière `solana_signer::Signer` compatible avec les consommateurs sans dépendance de `ks-lib` vers `ks-wallet`. +- [ ] fournir cette même capacité depuis un wallet natif ouvert par mot de passe. - [ ] importer le legacy Solana JSON vers `.kswallet` avec écriture atomique, rollback et vérification de pubkey. - [ ] importer et exporter le format keypair JSON standard des binaires Solana. - [ ] produire une matrice documentée des formats Phantom, Solflare, Backpack, Trust Wallet, Coinbase/Base et autres wallets Solana pertinents. diff --git a/ks-wallet/USAGE.md b/ks-wallet/USAGE.md index ba94581..4a0cf1e 100644 --- a/ks-wallet/USAGE.md +++ b/ks-wallet/USAGE.md @@ -1,11 +1,11 @@ - + # Utilisation de ks-wallet ## Statut -En `0.5.2-pre.001`, cette page décrit l'API runtime **actuelle**. La cible multi-wallet/password/`.kswallet` est planifiée mais n'est pas encore implémentée. +En `0.5.2-pre.002`, l'API multi-wallet de découverte est introduite, tandis que le password, le codec complet et la création persistante `.kswallet` restent planifiés pour les prereleases suivantes. ## Valider un alias @@ -37,10 +37,13 @@ let alias = match ks_wallet::WalletAlias::parse( }; let wallet = ks_wallet::TemporaryWallet::generate(alias); -let summary = wallet.summary(); +let identity = wallet.identity(); -assert!(summary.storage_path.is_none()); -println!("public key={}", summary.public_key); +assert_eq!( + identity.persistence, + ks_wallet::WalletPersistence::Temporary, +); +println!("public key={}", identity.public_key); ``` Cette capacité est durable : `0.5.2` doit continuer à permettre des wallets temporaires/jetables pour tests, démonstrations et scénarios sans imposer une persistance ou un mot de passe. @@ -106,8 +109,11 @@ let wallet = match store.create(alias).await { }, }; -let summary = wallet.summary(); -assert!(summary.storage_path.is_some()); +let identity = wallet.identity(); +assert_eq!( + identity.persistence, + ks_wallet::WalletPersistence::Persistent, +); ``` `create` refuse d'écraser un fichier existant. @@ -138,6 +144,56 @@ println!("wallet={}", wallet.public_key()); Le contenu legacy est toutefois écrit directement dans le chemin final. La publication n'est pas encore crash-safe/atomique par fichier temporaire + renommage. +## Manager multi-wallet introduit en `pre.002` + +Le manager reçoit le répertoire déjà résolu par le consommateur/configuration. Son scan automatique ne sort jamais de cette racine : + +```rust +let manager = match ks_wallet::WalletManager::new( + configured_wallet_directory, +) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, +}; + +let wallets = match manager.scan().await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, +}; +``` + +`scan()` est non récursif et ne traite que les `.kswallet`. En `pre.002`, l'identification vérifie le suffixe, l'alias, le type de fichier, les permissions Unix, le magic ASCII `KSWALLET` et la version `1` encodée en `u16` little-endian. La validation complète du payload arrivera avec le codec `pre.003`. + +Le lookup par alias reste limité à la racine configurée : + +```rust +let wallet = match manager.lookup(&alias).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, +}; +``` + +Pour un fichier choisi explicitement par l'utilisateur, par exemple via un file browser Tauri, le chemin peut être extérieur au store : + +```rust +let handle = match manager.inspect_file(selected_path).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, +}; + +println!("alias={}", handle.alias().as_str()); +``` + +`WalletFileHandle` conserve le chemin en interne mais ne fournit aucun getter public vers ce chemin et son `Debug` ne l'affiche pas. `inspect_file()` ne modifie pas la configuration et n'ajoute pas le fichier sélectionné au résultat de `scan()`. Le handle servira à l'ouverture par mot de passe dans la tranche correspondante. + ## Cible persistante `0.5.2` Le nouveau stockage natif doit utiliser : @@ -164,7 +220,7 @@ Le store découvrira les wallets persistants en scannant son répertoire résolu Le changement de mot de passe rechiffre la même keypair. Il n'existe pas de rotation normale du secret Ed25519 conservant la même pubkey : une nouvelle clé secrète signifie une nouvelle identité Solana. -Les noms Rust définitifs et le modèle runtime exact seront décidés dans les prereleases d'implémentation. +Le modèle runtime exact du wallet ouvert sera décidé dans les prereleases d'implémentation. Les noms `WalletManager`, `WalletFileHandle`, `WalletIdentity` et `WalletPersistence` font désormais partie de l'API de base. ## Import/export cible @@ -181,7 +237,7 @@ L'export vers Solana CLI utilise son tableau JSON de 64 octets. Pour Phantom, So - les octets secrets ne sont pas exposés par l'API publique par commodité ; - aucun mot de passe ni secret n'est projeté vers Tauri ; - `ks-config` ne stocke pas les mots de passe ; -- les résumés publics ne doivent pas exposer inutilement les chemins locaux ; +- `WalletIdentity`, `WalletSummary`, `WalletFileHandle::Debug` et les erreurs natives normales n'exposent pas le chemin local ; - un fichier existant n'est pas écrasé silencieusement ; - les liens symboliques et fichiers non réguliers sont refusés ; - sur Unix, les permissions privées sont vérifiées ; @@ -197,4 +253,4 @@ L'export vers Solana CLI utilise son tableau JSON de 64 octets. Pour Phantom, So - rejet des keypairs corrompus ; - vérification des permissions Unix privées. -La matrice `0.5.2` ajoutera notamment : legacy externalisé, `.kswallet`, password/changement de password, multi-wallet, migration, import/export, atomicité et canaris de non-divulgation. +`pre.002` ajoute la caractérisation legacy externalisée ainsi que les tests du manager, du scan, du lookup, de l'inspection hors store, du magic/version et de la non-divulgation du chemin par le handle. La suite de la matrice ajoutera le codec `.kswallet`, le password/changement de password, la migration, l'import/export, l'atomicité complète et les canaris de non-divulgation. diff --git a/ks-wallet/src/constants.rs b/ks-wallet/src/constants.rs index fce342d..afd37ad 100644 --- a/ks-wallet/src/constants.rs +++ b/ks-wallet/src/constants.rs @@ -1,9 +1,17 @@ // file: ks-wallet/src/constants.rs -// version: 2 +// version: 3 //! Local constants for the `ks-wallet` crate. /// Canonical tracing target for this crate. pub(crate) const TRACING_TARGET: &str = "ks-wallet"; +/// Native wallet file extension without the leading dot. +pub const KSWALLET_FILE_EXTENSION: &str = "kswallet"; +/// Native wallet format identification magic. +pub(crate) const KSWALLET_MAGIC: &[u8; 8] = b"KSWALLET"; +/// Native wallet format version used by the identification prefix. +pub(crate) const KSWALLET_FORMAT_VERSION: u16 = 1; +/// Number of bytes required to identify a native wallet file. +pub(crate) const KSWALLET_IDENTIFICATION_LENGTH: usize = 10; /// Number of bytes stored by the standard Solana keypair JSON format. pub(crate) const SOLANA_KEYPAIR_LENGTH: usize = 64; diff --git a/ks-wallet/src/lib.rs b/ks-wallet/src/lib.rs index 5f72fa6..6b34f75 100644 --- a/ks-wallet/src/lib.rs +++ b/ks-wallet/src/lib.rs @@ -1,5 +1,5 @@ // file: ks-wallet/src/lib.rs -// version: 3 +// version: 4 //! Wallet boundary for local key storage and transaction signing. #![warn(missing_docs)] @@ -7,20 +7,37 @@ #![forbid(unsafe_code)] mod constants; +mod manager; mod wallet; +/// Native wallet file extension without the leading dot. +pub use self::constants::KSWALLET_FILE_EXTENSION; +/// Opaque reference to one identified native wallet file. +pub use self::manager::WalletFileHandle; +/// Multi-wallet manager rooted at one configured wallet directory. +pub use self::manager::WalletManager; +/// Solana keypair kept private inside the wallet boundary. +pub use self::wallet::TemporaryWallet; +/// Filesystem-backed store for legacy development and integration-test wallets. +pub use self::wallet::TemporaryWalletStore; +/// Validated non-secret wallet alias. +pub use self::wallet::WalletAlias; +/// Minimal non-secret identity of a wallet. +pub use self::wallet::WalletIdentity; +/// Whether a runtime wallet is temporary or persistent. +pub use self::wallet::WalletPersistence; +/// Non-secret wallet policy. +pub use self::wallet::WalletPolicy; +/// Backward-compatible name for a non-secret wallet identity. +pub use self::wallet::WalletSummary; + +/// Native wallet format version used by the identification prefix. +pub(crate) use self::constants::KSWALLET_FORMAT_VERSION; +/// Number of bytes required to identify a native wallet file. +pub(crate) use self::constants::KSWALLET_IDENTIFICATION_LENGTH; +/// Native wallet format identification magic. +pub(crate) use self::constants::KSWALLET_MAGIC; /// 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/ks-wallet/src/manager.rs b/ks-wallet/src/manager.rs new file mode 100644 index 0000000..b36e79d --- /dev/null +++ b/ks-wallet/src/manager.rs @@ -0,0 +1,415 @@ +// file: ks-wallet/src/manager.rs +// version: 1 + +//! Multi-wallet discovery and native wallet file references. + +use tokio::io::AsyncReadExt; // rust-rules: trait-import + +/// Opaque reference to one identified 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, + 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).finish(); + } +} + +impl crate::WalletFileHandle { + /// Returns the validated alias inferred from the native wallet filename. + pub fn alias(&self) -> &crate::WalletAlias { + return &self.alias; + } +} + +/// 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) -> ks_core::Result { + 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 identified native wallets in the configured directory. + /// + /// Discovery is non-recursive and ignores unrelated file extensions. Every + /// `.kswallet` candidate must be a private regular file with a valid alias, + /// magic and supported identification version. Full payload validation is + /// added by the native codec tranche. + pub async fn scan(&self) -> ks_core::Result> { + 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> { + 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, + ) -> ks_core::Result { + return inspect_native_wallet_file(path.as_ref().to_path_buf()).await; + } +} + +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 { + 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 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), + } + match validate_native_wallet_identification(&path).await { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + return std::result::Result::Ok(crate::WalletFileHandle { alias, 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", + )); + } + 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(()); +} + +async fn validate_native_wallet_identification(path: &std::path::Path) -> ks_core::Result<()> { + let mut file = match tokio::fs::File::open(path).await { + std::result::Result::Ok(file) => file, + std::result::Result::Err(error) => { + return std::result::Result::Err(ks_core::Error::new( + "wallet_native_open_failed", + error.to_string(), + )); + }, + }; + let mut identification = [0_u8; crate::KSWALLET_IDENTIFICATION_LENGTH]; + match file.read_exact(&mut identification).await { + std::result::Result::Ok(_) => {}, + std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => { + return std::result::Result::Err(ks_core::Error::new( + "wallet_native_identification_truncated", + "native wallet identification prefix is truncated", + )); + }, + std::result::Result::Err(error) => { + return std::result::Result::Err(ks_core::Error::new( + "wallet_native_identification_read_failed", + error.to_string(), + )); + }, + } + if identification[0..crate::KSWALLET_MAGIC.len()] != crate::KSWALLET_MAGIC[..] { + return std::result::Result::Err(ks_core::Error::new( + "wallet_native_magic_invalid", + "native wallet magic is invalid", + )); + } + let version_offset = crate::KSWALLET_MAGIC.len(); + let version = + u16::from_le_bytes([identification[version_offset], identification[version_offset + 1]]); + if version != crate::KSWALLET_FORMAT_VERSION { + return std::result::Result::Err(ks_core::Error::new( + "wallet_native_version_unsupported", + format!("native wallet version {version} is not supported"), + )); + } + return std::result::Result::Ok(()); +} + +#[cfg(test)] +mod tests { + fn write_identified_wallet(path: &std::path::Path, version: u16) { + let mut bytes = std::vec::Vec::new(); + bytes.extend_from_slice(crate::KSWALLET_MAGIC); + bytes.extend_from_slice(&version.to_le_bytes()); + 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}")); + } + } + + #[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}")); + let manager = crate::WalletManager::new(directory.path()) + .unwrap_or_else(|error| panic!("unexpected manager error: {error}")); + write_identified_wallet(&directory.path().join("alpha.kswallet"), 1); + 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_identified_wallet(&nested.join("hidden.kswallet"), 1); + 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"); + } + + #[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_identified_wallet(&manager.wallet_path(&alias), 1); + 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!(!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}")); + 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("selected.kswallet"); + write_identified_wallet(&path, 1); + let handle = manager + .inspect_file(&path) + .await + .unwrap_or_else(|error| panic!("explicit inspection must succeed: {error}")); + assert_eq!(handle.alias().as_str(), "selected"); + assert_eq!(handle.path, path); + let scanned = manager + .scan() + .await + .unwrap_or_else(|error| panic!("configured scan must succeed: {error}")); + assert!(scanned.is_empty()); + } + + #[tokio::test] + async fn unsupported_native_version_is_rejected_without_path_disclosure() { + 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 path = directory.path().join("future.kswallet"); + write_identified_wallet(&path, 999); + let error = manager + .inspect_file(&path) + .await + .err() + .unwrap_or_else(|| panic!("unsupported version must fail")); + assert_eq!(error.code(), "wallet_native_version_unsupported"); + assert!(!error.to_string().contains(directory.path().to_string_lossy().as_ref())); + } + + #[tokio::test] + async fn invalid_native_magic_is_rejected() { + 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 path = directory.path().join("invalid.kswallet"); + write_identified_wallet(&path, 1); + let mut bytes = std::fs::read(&path) + .unwrap_or_else(|error| panic!("native fixture must be readable: {error}")); + bytes[0] = b'X'; + std::fs::write(&path, bytes) + .unwrap_or_else(|error| panic!("native fixture must be rewritable: {error}")); + let error = manager + .inspect_file(&path) + .await + .err() + .unwrap_or_else(|| panic!("invalid magic must fail")); + assert_eq!(error.code(), "wallet_native_magic_invalid"); + } +} diff --git a/ks-wallet/src/wallet.rs b/ks-wallet/src/wallet.rs index 83c4b9c..8c77630 100644 --- a/ks-wallet/src/wallet.rs +++ b/ks-wallet/src/wallet.rs @@ -1,5 +1,5 @@ // file: ks-wallet/src/wallet.rs -// version: 10 +// version: 11 //! Local wallet storage and signing primitives. @@ -46,17 +46,29 @@ pub struct WalletPolicy { pub lamport_spend_limit: std::option::Option, } -/// Non-secret description of a loaded wallet. +/// Whether a runtime wallet is temporary or persistent. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WalletPersistence { + /// Wallet exists only in runtime memory. + Temporary, + /// Wallet originates from persistent local storage. + Persistent, +} + +/// Minimal non-secret identity of a wallet. #[derive(Clone, Debug, Eq, PartialEq)] -pub struct WalletSummary { - /// Validated local alias. +pub struct WalletIdentity { + /// Validated logical 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, + /// Whether the runtime wallet is temporary or persistent. + pub persistence: crate::WalletPersistence, } +/// Backward-compatible name for the non-secret wallet identity. +pub type WalletSummary = crate::WalletIdentity; + /// Solana keypair kept private inside the wallet boundary. pub struct TemporaryWallet { alias: crate::WalletAlias, @@ -70,7 +82,7 @@ impl std::fmt::Debug for crate::TemporaryWallet { .debug_struct("TemporaryWallet") .field("alias", &self.alias) .field("public_key", &self.public_key()) - .field("storage_path", &self.storage_path) + .field("persistence", &self.persistence()) .finish(); } } @@ -85,15 +97,28 @@ impl crate::TemporaryWallet { }; } - /// Returns a non-secret summary suitable for logs, CLI output or application adapters. - pub fn summary(&self) -> crate::WalletSummary { - return crate::WalletSummary { + /// Returns the minimal non-secret wallet identity. + pub fn identity(&self) -> crate::WalletIdentity { + return crate::WalletIdentity { alias: self.alias.clone(), public_key: self.public_key(), - storage_path: self.storage_path.clone(), + persistence: self.persistence(), }; } + /// Returns the non-secret wallet identity using the historical API name. + pub fn summary(&self) -> crate::WalletSummary { + return self.identity(); + } + + /// Returns whether this runtime wallet is temporary or persistent. + pub fn persistence(&self) -> crate::WalletPersistence { + if self.storage_path.is_some() { + return crate::WalletPersistence::Persistent; + } + return crate::WalletPersistence::Temporary; + } + /// Returns the wallet public key in base58 form. pub fn public_key(&self) -> std::string::String { return self.keypair.pubkey().to_string(); @@ -443,7 +468,7 @@ mod tests { assert!(!signature.is_empty()); assert_eq!(wallet.public_key(), wallet.as_signer().pubkey().to_string()); assert_eq!(wallet.public_key(), wallet.as_sync_signer().pubkey().to_string()); - assert!(wallet.summary().storage_path.is_none()); + assert_eq!(wallet.summary().persistence, crate::WalletPersistence::Temporary); assert!(!format!("{wallet:?}").contains("secret")); } @@ -464,10 +489,7 @@ mod tests { .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)) - ); + assert_eq!(created.summary().persistence, crate::WalletPersistence::Persistent); 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()) diff --git a/ks-wallet/tests/legacy_characterization.rs b/ks-wallet/tests/legacy_characterization.rs new file mode 100644 index 0000000..8e0f0c1 --- /dev/null +++ b/ks-wallet/tests/legacy_characterization.rs @@ -0,0 +1,184 @@ +// file: ks-wallet/tests/legacy_characterization.rs +// version: 1 + +//! External characterization tests for the legacy Solana JSON wallet format. + +use solana_signer::Signer; // rust-rules: trait-import +use zeroize::Zeroize; // rust-rules: trait-import + +fn write_legacy_fixture(path: &std::path::Path, keypair: &solana_keypair::Keypair) { + let mut keypair_bytes = keypair.to_bytes(); + let encoded = serde_json::to_vec(keypair_bytes.as_slice()) + .unwrap_or_else(|error| panic!("legacy fixture must serialize: {error}")); + keypair_bytes.zeroize(); + std::fs::write(path, encoded) + .unwrap_or_else(|error| panic!("legacy 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!("legacy fixture permissions must be set: {error}")); + } +} + +#[test] +fn legacy_path_is_alias_json() { + let directory = tempfile::tempdir() + .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); + let store = ks_wallet::TemporaryWalletStore::new(directory.path()) + .unwrap_or_else(|error| panic!("unexpected store error: {error}")); + let alias = ks_wallet::WalletAlias::parse("legacy-name") + .unwrap_or_else(|error| panic!("unexpected alias error: {error}")); + assert_eq!( + store.wallet_path(&alias).file_name(), + std::option::Option::Some(std::ffi::OsStr::new("legacy-name.json")) + ); +} + +#[tokio::test] +async fn legacy_standard_json_fixture_preserves_public_key() { + let directory = tempfile::tempdir() + .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); + let store = ks_wallet::TemporaryWalletStore::new(directory.path()) + .unwrap_or_else(|error| panic!("unexpected store error: {error}")); + let alias = ks_wallet::WalletAlias::parse("legacy-fixture") + .unwrap_or_else(|error| panic!("unexpected alias error: {error}")); + let expected = solana_keypair::Keypair::new(); + write_legacy_fixture(&store.wallet_path(&alias), &expected); + let loaded = store + .load(alias) + .await + .unwrap_or_else(|error| panic!("legacy fixture must load: {error}")); + assert_eq!(loaded.public_key(), expected.pubkey().to_string()); + assert_eq!(loaded.persistence(), ks_wallet::WalletPersistence::Persistent); +} + +#[tokio::test] +async fn legacy_missing_file_preserves_current_error_contract() { + let directory = tempfile::tempdir() + .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); + let store = ks_wallet::TemporaryWalletStore::new(directory.path()) + .unwrap_or_else(|error| panic!("unexpected store error: {error}")); + let alias = ks_wallet::WalletAlias::parse("missing") + .unwrap_or_else(|error| panic!("unexpected alias error: {error}")); + let error = store + .load(alias) + .await + .err() + .unwrap_or_else(|| panic!("missing legacy wallet must fail")); + assert_eq!(error.code(), "wallet_file_metadata_failed"); +} + +#[tokio::test] +async fn legacy_corrupted_json_and_short_keypair_are_distinct() { + let directory = tempfile::tempdir() + .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); + let store = ks_wallet::TemporaryWalletStore::new(directory.path()) + .unwrap_or_else(|error| panic!("unexpected store error: {error}")); + let corrupted_alias = ks_wallet::WalletAlias::parse("corrupted-json") + .unwrap_or_else(|error| panic!("unexpected alias error: {error}")); + let corrupted_path = store.wallet_path(&corrupted_alias); + std::fs::write(&corrupted_path, b"not-json") + .unwrap_or_else(|error| panic!("corrupted fixture must be writable: {error}")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import + std::fs::set_permissions(&corrupted_path, std::fs::Permissions::from_mode(0o600)) + .unwrap_or_else(|error| panic!("fixture permissions must be set: {error}")); + } + let corrupted_error = store + .load(corrupted_alias) + .await + .err() + .unwrap_or_else(|| panic!("corrupted JSON must fail")); + assert_eq!(corrupted_error.code(), "wallet_keypair_json_invalid"); + let short_alias = ks_wallet::WalletAlias::parse("short-keypair") + .unwrap_or_else(|error| panic!("unexpected alias error: {error}")); + let short_path = store.wallet_path(&short_alias); + std::fs::write(&short_path, b"[1,2,3]") + .unwrap_or_else(|error| panic!("short fixture must be writable: {error}")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import + std::fs::set_permissions(&short_path, std::fs::Permissions::from_mode(0o600)) + .unwrap_or_else(|error| panic!("fixture permissions must be set: {error}")); + } + let short_error = store + .load(short_alias) + .await + .err() + .unwrap_or_else(|| panic!("short keypair must fail")); + assert_eq!(short_error.code(), "wallet_keypair_length_invalid"); +} + +#[tokio::test] +async fn legacy_create_refuses_existing_wallet_and_preserves_first_pubkey() { + let directory = tempfile::tempdir() + .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); + let store = ks_wallet::TemporaryWalletStore::new(directory.path()) + .unwrap_or_else(|error| panic!("unexpected store error: {error}")); + let alias = ks_wallet::WalletAlias::parse("collision") + .unwrap_or_else(|error| panic!("unexpected alias error: {error}")); + let first = store + .create(alias.clone()) + .await + .unwrap_or_else(|error| panic!("first legacy wallet must be created: {error}")); + let second_error = store + .create(alias.clone()) + .await + .err() + .unwrap_or_else(|| panic!("second legacy create must fail")); + assert_eq!(second_error.code(), "wallet_file_already_exists"); + let loaded = store + .load(alias) + .await + .unwrap_or_else(|error| panic!("first legacy wallet must remain readable: {error}")); + assert_eq!(loaded.public_key(), first.public_key()); +} + +#[cfg(unix)] +#[tokio::test] +async fn legacy_file_with_open_permissions_is_rejected() { + 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 = ks_wallet::TemporaryWalletStore::new(directory.path()) + .unwrap_or_else(|error| panic!("unexpected store error: {error}")); + let alias = ks_wallet::WalletAlias::parse("open-permissions") + .unwrap_or_else(|error| panic!("unexpected alias error: {error}")); + let keypair = solana_keypair::Keypair::new(); + let path = store.wallet_path(&alias); + write_legacy_fixture(&path, &keypair); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) + .unwrap_or_else(|error| panic!("fixture permissions must be changed: {error}")); + let error = store + .load(alias) + .await + .err() + .unwrap_or_else(|| panic!("open permissions must fail")); + assert_eq!(error.code(), "wallet_file_permissions_too_open"); +} + +#[cfg(unix)] +#[tokio::test] +async fn legacy_symlink_is_rejected() { + let directory = tempfile::tempdir() + .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); + let store = ks_wallet::TemporaryWalletStore::new(directory.path()) + .unwrap_or_else(|error| panic!("unexpected store error: {error}")); + let target_alias = ks_wallet::WalletAlias::parse("target") + .unwrap_or_else(|error| panic!("unexpected alias error: {error}")); + let link_alias = ks_wallet::WalletAlias::parse("linked") + .unwrap_or_else(|error| panic!("unexpected alias error: {error}")); + let keypair = solana_keypair::Keypair::new(); + let target_path = store.wallet_path(&target_alias); + write_legacy_fixture(&target_path, &keypair); + std::os::unix::fs::symlink(&target_path, store.wallet_path(&link_alias)) + .unwrap_or_else(|error| panic!("legacy symlink fixture must be created: {error}")); + let error = store + .load(link_alias) + .await + .err() + .unwrap_or_else(|| panic!("legacy symlink must fail")); + assert_eq!(error.code(), "wallet_file_type_invalid"); +} diff --git a/ks-wallet/tests/public_api.rs b/ks-wallet/tests/public_api.rs new file mode 100644 index 0000000..da0b72e --- /dev/null +++ b/ks-wallet/tests/public_api.rs @@ -0,0 +1,48 @@ +// file: ks-wallet/tests/public_api.rs +// version: 1 + +//! External crate-root contract tests for the `ks-wallet` public API. + +fn write_native_identification_fixture(path: &std::path::Path) { + let mut bytes = std::vec::Vec::new(); + bytes.extend_from_slice(b"KSWALLET"); + bytes.extend_from_slice(&1_u16.to_le_bytes()); + 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}")); + } +} + +#[tokio::test] +async fn crate_root_exposes_multi_wallet_discovery_without_path_disclosure() { + assert_eq!(ks_wallet::KSWALLET_FILE_EXTENSION, "kswallet"); + let directory = tempfile::tempdir() + .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); + let manager = ks_wallet::WalletManager::new(directory.path()) + .unwrap_or_else(|error| panic!("unexpected manager error: {error}")); + let path = directory.path().join("public-api.kswallet"); + write_native_identification_fixture(&path); + let handles = manager + .scan() + .await + .unwrap_or_else(|error| panic!("scan must succeed: {error}")); + assert_eq!(handles.len(), 1); + assert_eq!(handles[0].alias().as_str(), "public-api"); + assert!(!format!("{:?}", handles[0]).contains(directory.path().to_string_lossy().as_ref())); +} + +#[test] +fn crate_root_exposes_non_secret_temporary_identity() { + let alias = ks_wallet::WalletAlias::parse("public-identity") + .unwrap_or_else(|error| panic!("unexpected alias error: {error}")); + let wallet = ks_wallet::TemporaryWallet::generate(alias.clone()); + let identity: ks_wallet::WalletIdentity = wallet.identity(); + let summary: ks_wallet::WalletSummary = wallet.summary(); + assert_eq!(identity.alias, alias); + assert_eq!(identity.public_key, summary.public_key); + assert_eq!(identity.persistence, ks_wallet::WalletPersistence::Temporary); +}