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

View File

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

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
// version: 10
// version: 11
//! Local wallet storage and signing primitives.
@@ -46,17 +46,29 @@ pub struct WalletPolicy {
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)]
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<std::path::PathBuf>,
/// 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::<std::vec::Vec<u8>>(encoded.as_slice())