v0.5.2-pre.002

This commit is contained in:
2026-08-10 15:06:38 +02:00
parent 0bd5bbf1c4
commit eaa24e6f11
12 changed files with 835 additions and 58 deletions

View File

@@ -1,5 +1,5 @@
# file: Cargo.toml # file: Cargo.toml
# version: 56 # version: 57
[workspace] [workspace]
resolver = "3" resolver = "3"
@@ -18,7 +18,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.5.2-pre.1" version = "0.5.2-pre.2"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-bot3" repository = "https://git.sasedev.com/Sasedev/khadhroony-bot3"

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/V0_5_2_KS_WALLET_RESTRUCTURING_PLAN.md --> <!-- file: docs/plans/V0_5_2_KS_WALLET_RESTRUCTURING_PLAN.md -->
<!-- version: 3 --> <!-- version: 4 -->
# Plan `0.5.2` — restructuration de `ks-wallet` # 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é. 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 ### 2.2 Wallets temporaires ou jetables
`ks-wallet` doit conserver la capacité actuelle de générer des wallets temporaires en mémoire pour : `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 ### `0.5.2-pre.002` — caractérisation exécutable et API de base
- tests externes du legacy ; - tests externes du legacy ;
- identité publique minimale ; - identité publique minimale sans chemin local ;
- manager/scan/listing/lookup multi-wallet ; - 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 ; - contrat de wallet temporaire ;
- frontière de signature compatible avec `ks-lib`. - 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` ### `0.5.2-pre.003` — codec et stockage `.kswallet`
- spécification binaire versionnée ; - 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` ; 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 ; 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 ; 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`.

View File

@@ -1,8 +1,21 @@
<!-- file: ks-wallet/CHANGELOG.md --> <!-- file: ks-wallet/CHANGELOG.md -->
<!-- version: 9 --> <!-- version: 10 -->
# CHANGELOG — ks-wallet # 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` ## `0.5.2-pre.001`
- caractérise précisément le format legacy `<alias>.json`, ses permissions Unix, ses erreurs, sa publication directe dans le chemin final et ses limites datomicité/TOCTOU ; - caractérise précisément le format legacy `<alias>.json`, ses permissions Unix, ses erreurs, sa publication directe dans le chemin final et ses limites datomicité/TOCTOU ;

View File

@@ -1,5 +1,5 @@
<!-- file: ks-wallet/README.md --> <!-- file: ks-wallet/README.md -->
<!-- version: 6 --> <!-- version: 7 -->
# ks-wallet # ks-wallet
@@ -7,15 +7,18 @@
## État actuel ## É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 : La crate fournit actuellement :
- alias validés ; - 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 ; - 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` ; - 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 ; - accès au trait `Signer` sans exposition des octets ;
- signature de messages ; - signature de messages ;
- permissions Unix privées et contrôles de fichiers ; - 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 ; - 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. - 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 ## Relations

View File

@@ -1,19 +1,21 @@
<!-- file: ks-wallet/TODO.md --> <!-- file: ks-wallet/TODO.md -->
<!-- version: 7 --> <!-- version: 8 -->
# TODO — ks-wallet # TODO — ks-wallet
## `0.5.2` ## `0.5.2`
- [ ] caractériser par tests externes le format legacy `<alias>.json`, ses erreurs et ses permissions. - [x] caractériser par tests externes le format legacy `<alias>.json`, ses erreurs et ses permissions.
- [ ] définir l'identité publique minimale d'un wallet et le lookup par alias. - [x] 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 `<alias>.kswallet` du store résolu. - [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`.
- [ ] conserver/généraliser les wallets temporaires ou jetables purement en mémoire. - [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é `<alias>.kswallet`. - [ ] spécifier puis implémenter le format natif binaire versionné `<alias>.kswallet`.
- [ ] sélectionner et documenter KDF/AEAD/paramètres après vérification des dépendances résolues. - [ ] 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. - [ ] 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. - [ ] 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 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. - [ ] 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. - [ ] produire une matrice documentée des formats Phantom, Solflare, Backpack, Trust Wallet, Coinbase/Base et autres wallets Solana pertinents.

View File

@@ -1,11 +1,11 @@
<!-- file: ks-wallet/USAGE.md --> <!-- file: ks-wallet/USAGE.md -->
<!-- version: 6 --> <!-- version: 7 -->
# Utilisation de ks-wallet # Utilisation de ks-wallet
## Statut ## 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 ## Valider un alias
@@ -37,10 +37,13 @@ let alias = match ks_wallet::WalletAlias::parse(
}; };
let wallet = ks_wallet::TemporaryWallet::generate(alias); let wallet = ks_wallet::TemporaryWallet::generate(alias);
let summary = wallet.summary(); let identity = wallet.identity();
assert!(summary.storage_path.is_none()); assert_eq!(
println!("public key={}", summary.public_key); 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. 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(); let identity = wallet.identity();
assert!(summary.storage_path.is_some()); assert_eq!(
identity.persistence,
ks_wallet::WalletPersistence::Persistent,
);
``` ```
`create` refuse d'écraser un fichier existant. `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. 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 `<alias>.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` ## Cible persistante `0.5.2`
Le nouveau stockage natif doit utiliser : 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. 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 ## 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é ; - 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 ; - aucun mot de passe ni secret n'est projeté vers Tauri ;
- `ks-config` ne stocke pas les mots de passe ; - `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 ; - un fichier existant n'est pas écrasé silencieusement ;
- les liens symboliques et fichiers non réguliers sont refusés ; - les liens symboliques et fichiers non réguliers sont refusés ;
- sur Unix, les permissions privées sont vérifiées ; - 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 ; - rejet des keypairs corrompus ;
- vérification des permissions Unix privées. - 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.

View File

@@ -1,9 +1,17 @@
// file: ks-wallet/src/constants.rs // file: ks-wallet/src/constants.rs
// version: 2 // version: 3
//! Local constants for the `ks-wallet` crate. //! Local constants for the `ks-wallet` crate.
/// Canonical tracing target for this crate. /// Canonical tracing target for this crate.
pub(crate) const TRACING_TARGET: &str = "ks-wallet"; 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. /// Number of bytes stored by the standard Solana keypair JSON format.
pub(crate) const SOLANA_KEYPAIR_LENGTH: usize = 64; pub(crate) const SOLANA_KEYPAIR_LENGTH: usize = 64;

View File

@@ -1,5 +1,5 @@
// file: ks-wallet/src/lib.rs // file: ks-wallet/src/lib.rs
// version: 3 // version: 4
//! Wallet boundary for local key storage and transaction signing. //! Wallet boundary for local key storage and transaction signing.
#![warn(missing_docs)] #![warn(missing_docs)]
@@ -7,20 +7,37 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
mod constants; mod constants;
mod manager;
mod wallet; 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. /// Number of bytes stored by the standard Solana keypair JSON format.
pub(crate) use self::constants::SOLANA_KEYPAIR_LENGTH; pub(crate) use self::constants::SOLANA_KEYPAIR_LENGTH;
/// Canonical tracing target for this crate. /// Canonical tracing target for this crate.
pub(crate) use self::constants::TRACING_TARGET; 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;

415
ks-wallet/src/manager.rs Normal file
View File

@@ -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<std::path::PathBuf>) -> ks_core::Result<Self> {
let directory = directory.into();
if directory.as_os_str().is_empty() {
return std::result::Result::Err(ks_core::Error::new(
"wallet_directory_empty",
"wallet directory must not be empty",
));
}
return std::result::Result::Ok(Self { directory });
}
/// Returns the configured directory used for automatic wallet discovery.
pub fn directory(&self) -> &std::path::Path {
return self.directory.as_path();
}
fn wallet_path(&self, alias: &crate::WalletAlias) -> std::path::PathBuf {
return self.directory.join(format!(
"{}.{}",
alias.as_str(),
crate::KSWALLET_FILE_EXTENSION
));
}
/// Discovers 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<std::vec::Vec<crate::WalletFileHandle>> {
let directory_exists = match tokio::fs::try_exists(&self.directory).await {
std::result::Result::Ok(exists) => exists,
std::result::Result::Err(error) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_directory_exists_check_failed",
error.to_string(),
));
},
};
if !directory_exists {
return std::result::Result::Ok(std::vec::Vec::new());
}
match validate_wallet_directory(&self.directory).await {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let mut entries = match tokio::fs::read_dir(&self.directory).await {
std::result::Result::Ok(entries) => entries,
std::result::Result::Err(error) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_directory_read_failed",
error.to_string(),
));
},
};
let mut wallets = std::vec::Vec::new();
loop {
let entry = match entries.next_entry().await {
std::result::Result::Ok(std::option::Option::Some(entry)) => entry,
std::result::Result::Ok(std::option::Option::None) => break,
std::result::Result::Err(error) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_directory_entry_read_failed",
error.to_string(),
));
},
};
let path = entry.path();
if !has_native_wallet_extension(&path) {
continue;
}
let wallet = match inspect_native_wallet_file(path).await {
std::result::Result::Ok(wallet) => wallet,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
wallets.push(wallet);
}
wallets.sort_by(|left, right| return left.alias.cmp(&right.alias));
return std::result::Result::Ok(wallets);
}
/// Looks up one native wallet by alias in the configured directory.
pub async fn lookup(
&self,
alias: &crate::WalletAlias,
) -> ks_core::Result<std::option::Option<crate::WalletFileHandle>> {
let path = self.wallet_path(alias);
let exists = match tokio::fs::try_exists(&path).await {
std::result::Result::Ok(exists) => exists,
std::result::Result::Err(error) => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_file_exists_check_failed",
error.to_string(),
));
},
};
if !exists {
return std::result::Result::Ok(std::option::Option::None);
}
let wallet = match inspect_native_wallet_file(path).await {
std::result::Result::Ok(wallet) => wallet,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(std::option::Option::Some(wallet));
}
/// Inspects an explicitly selected native wallet file at any local path.
///
/// The selected path does not need to be inside the configured discovery
/// directory. Successful inspection does not register, copy or move it.
pub async fn inspect_file(
&self,
path: impl std::convert::AsRef<std::path::Path>,
) -> ks_core::Result<crate::WalletFileHandle> {
return inspect_native_wallet_file(path.as_ref().to_path_buf()).await;
}
}
fn has_native_wallet_extension(path: &std::path::Path) -> bool {
return path.extension()
== std::option::Option::Some(std::ffi::OsStr::new(crate::KSWALLET_FILE_EXTENSION));
}
async fn inspect_native_wallet_file(
path: std::path::PathBuf,
) -> ks_core::Result<crate::WalletFileHandle> {
if !has_native_wallet_extension(&path) {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_extension_invalid",
"native wallet file must use the .kswallet extension",
));
}
let stem = match path.file_stem().and_then(std::ffi::OsStr::to_str) {
std::option::Option::Some(stem) => stem,
std::option::Option::None => {
return std::result::Result::Err(ks_core::Error::new(
"wallet_native_alias_encoding_invalid",
"native wallet filename must contain a UTF-8 wallet alias",
));
},
};
let 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");
}
}

View File

@@ -1,5 +1,5 @@
// file: ks-wallet/src/wallet.rs // file: ks-wallet/src/wallet.rs
// version: 10 // version: 11
//! Local wallet storage and signing primitives. //! Local wallet storage and signing primitives.
@@ -46,17 +46,29 @@ pub struct WalletPolicy {
pub lamport_spend_limit: std::option::Option<u64>, pub lamport_spend_limit: std::option::Option<u64>,
} }
/// 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)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct WalletSummary { pub struct WalletIdentity {
/// Validated local alias. /// Validated logical alias.
pub alias: crate::WalletAlias, pub alias: crate::WalletAlias,
/// Base58 Solana public key. /// Base58 Solana public key.
pub public_key: std::string::String, pub public_key: std::string::String,
/// Persistent keypair path when the wallet was loaded from local storage. /// Whether the runtime wallet is temporary or persistent.
pub storage_path: std::option::Option<std::path::PathBuf>, 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. /// Solana keypair kept private inside the wallet boundary.
pub struct TemporaryWallet { pub struct TemporaryWallet {
alias: crate::WalletAlias, alias: crate::WalletAlias,
@@ -70,7 +82,7 @@ impl std::fmt::Debug for crate::TemporaryWallet {
.debug_struct("TemporaryWallet") .debug_struct("TemporaryWallet")
.field("alias", &self.alias) .field("alias", &self.alias)
.field("public_key", &self.public_key()) .field("public_key", &self.public_key())
.field("storage_path", &self.storage_path) .field("persistence", &self.persistence())
.finish(); .finish();
} }
} }
@@ -85,15 +97,28 @@ impl crate::TemporaryWallet {
}; };
} }
/// Returns a non-secret summary suitable for logs, CLI output or application adapters. /// Returns the minimal non-secret wallet identity.
pub fn summary(&self) -> crate::WalletSummary { pub fn identity(&self) -> crate::WalletIdentity {
return crate::WalletSummary { return crate::WalletIdentity {
alias: self.alias.clone(), alias: self.alias.clone(),
public_key: self.public_key(), 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. /// Returns the wallet public key in base58 form.
pub fn public_key(&self) -> std::string::String { pub fn public_key(&self) -> std::string::String {
return self.keypair.pubkey().to_string(); return self.keypair.pubkey().to_string();
@@ -443,7 +468,7 @@ mod tests {
assert!(!signature.is_empty()); assert!(!signature.is_empty());
assert_eq!(wallet.public_key(), wallet.as_signer().pubkey().to_string()); assert_eq!(wallet.public_key(), wallet.as_signer().pubkey().to_string());
assert_eq!(wallet.public_key(), wallet.as_sync_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")); assert!(!format!("{wallet:?}").contains("secret"));
} }
@@ -464,10 +489,7 @@ mod tests {
.await .await
.unwrap_or_else(|error| panic!("unexpected load error: {error}")); .unwrap_or_else(|error| panic!("unexpected load error: {error}"));
assert_eq!(created.public_key(), loaded.public_key()); assert_eq!(created.public_key(), loaded.public_key());
assert_eq!( assert_eq!(created.summary().persistence, crate::WalletPersistence::Persistent);
created.summary().storage_path,
std::option::Option::Some(store.wallet_path(&alias))
);
let encoded = std::fs::read(store.wallet_path(&alias)) let encoded = std::fs::read(store.wallet_path(&alias))
.unwrap_or_else(|error| panic!("wallet file must be readable: {error}")); .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()) let keypair_bytes = serde_json::from_slice::<std::vec::Vec<u8>>(encoded.as_slice())

View File

@@ -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");
}

View File

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