280 lines
12 KiB
Rust
280 lines
12 KiB
Rust
// file: ks-wallet/tests/transfer.rs
|
|
// version: 1
|
|
|
|
//! External migration and secret import/export contract tests.
|
|
|
|
use solana_signer::Signer; // rust-rules: trait-import
|
|
use zeroize::Zeroize; // rust-rules: trait-import
|
|
|
|
fn make_directory_private(path: &std::path::Path) {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
|
|
.unwrap_or_else(|error| panic!("fixture directory permissions must be set: {error}"));
|
|
}
|
|
}
|
|
|
|
fn write_private_file(path: &std::path::Path, bytes: &[u8]) {
|
|
use std::io::Write; // rust-rules: trait-import
|
|
let mut options = std::fs::OpenOptions::new();
|
|
options.write(true).create_new(true);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::OpenOptionsExt; // rust-rules: trait-import
|
|
options.mode(0o600);
|
|
}
|
|
let mut file = options
|
|
.open(path)
|
|
.unwrap_or_else(|error| panic!("private fixture must be created: {error}"));
|
|
file.write_all(bytes)
|
|
.unwrap_or_else(|error| panic!("private fixture must be written: {error}"));
|
|
file.sync_all()
|
|
.unwrap_or_else(|error| panic!("private fixture must be synchronized: {error}"));
|
|
}
|
|
|
|
fn password(value: &str) -> ks_wallet::WalletPassword {
|
|
return ks_wallet::WalletPassword::new(value.to_owned())
|
|
.unwrap_or_else(|error| panic!("test password must be accepted: {error}"));
|
|
}
|
|
|
|
fn alias(value: &str) -> ks_wallet::WalletAlias {
|
|
return ks_wallet::WalletAlias::parse(value)
|
|
.unwrap_or_else(|error| panic!("test alias must be accepted: {error}"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn solana_cli_json_import_export_roundtrip_preserves_exact_keypair() {
|
|
let directory = tempfile::tempdir()
|
|
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
|
make_directory_private(directory.path());
|
|
let manager = ks_wallet::WalletManager::new(directory.path())
|
|
.unwrap_or_else(|error| panic!("manager must be created: {error}"));
|
|
let keypair = solana_keypair::Keypair::new();
|
|
let expected_public_key = keypair.pubkey().to_string();
|
|
let mut source_keypair_bytes = keypair.to_bytes();
|
|
let mut source_json = serde_json::to_vec(source_keypair_bytes.as_slice())
|
|
.unwrap_or_else(|error| panic!("Solana JSON fixture must encode: {error}"));
|
|
let source_path = directory.path().join("solana-cli-source.json");
|
|
write_private_file(&source_path, source_json.as_slice());
|
|
source_json.zeroize();
|
|
let imported = manager
|
|
.import_file(
|
|
alias("cli-import"),
|
|
password("cli-import-password"),
|
|
&source_path,
|
|
ks_wallet::WalletTransferFormat::SolanaCliJson,
|
|
)
|
|
.await
|
|
.unwrap_or_else(|error| panic!("Solana CLI import must succeed: {error}"));
|
|
assert_eq!(imported.public_key(), expected_public_key);
|
|
imported.lock();
|
|
let export_path = directory.path().join("solana-cli-export.json");
|
|
manager
|
|
.export_file(
|
|
&alias("cli-import"),
|
|
password("cli-import-password"),
|
|
&export_path,
|
|
ks_wallet::WalletTransferFormat::SolanaCliJson,
|
|
)
|
|
.await
|
|
.unwrap_or_else(|error| panic!("Solana CLI export must succeed: {error}"));
|
|
let mut exported = std::fs::read(&export_path)
|
|
.unwrap_or_else(|error| panic!("Solana CLI export must be readable: {error}"));
|
|
let mut decoded = serde_json::from_slice::<std::vec::Vec<u8>>(exported.as_slice())
|
|
.unwrap_or_else(|error| panic!("Solana CLI export must decode: {error}"));
|
|
exported.zeroize();
|
|
assert_eq!(decoded.as_slice(), source_keypair_bytes.as_slice());
|
|
decoded.zeroize();
|
|
source_keypair_bytes.zeroize();
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
|
|
let metadata = std::fs::metadata(&export_path)
|
|
.unwrap_or_else(|error| panic!("export metadata must be readable: {error}"));
|
|
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
|
|
}
|
|
let overwrite_error = manager
|
|
.export_file(
|
|
&alias("cli-import"),
|
|
password("cli-import-password"),
|
|
&export_path,
|
|
ks_wallet::WalletTransferFormat::SolanaCliJson,
|
|
)
|
|
.await
|
|
.err()
|
|
.unwrap_or_else(|| panic!("secret export must refuse an existing destination"));
|
|
assert_eq!(overwrite_error.code(), "wallet_export_already_exists");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn base58_private_key_adapter_roundtrips_phantom_style_keypair() {
|
|
let directory = tempfile::tempdir()
|
|
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
|
make_directory_private(directory.path());
|
|
let manager = ks_wallet::WalletManager::new(directory.path())
|
|
.unwrap_or_else(|error| panic!("manager must be created: {error}"));
|
|
let keypair = solana_keypair::Keypair::new();
|
|
let expected_public_key = keypair.pubkey().to_string();
|
|
let mut keypair_bytes = keypair.to_bytes();
|
|
let mut base58_private_key = bs58::encode(keypair_bytes.as_slice()).into_string();
|
|
let source_path = directory.path().join("phantom-private-key.txt");
|
|
write_private_file(&source_path, base58_private_key.as_bytes());
|
|
let imported = manager
|
|
.import_file(
|
|
alias("phantom-import"),
|
|
password("phantom-import-password"),
|
|
&source_path,
|
|
ks_wallet::WalletTransferFormat::SolanaPrivateKeyBase58,
|
|
)
|
|
.await
|
|
.unwrap_or_else(|error| panic!("Base58 private-key import must succeed: {error}"));
|
|
assert_eq!(imported.public_key(), expected_public_key);
|
|
imported.lock();
|
|
let export_path = directory.path().join("phantom-private-key-export.txt");
|
|
manager
|
|
.export_file(
|
|
&alias("phantom-import"),
|
|
password("phantom-import-password"),
|
|
&export_path,
|
|
ks_wallet::WalletTransferFormat::SolanaPrivateKeyBase58,
|
|
)
|
|
.await
|
|
.unwrap_or_else(|error| panic!("Base58 private-key export must succeed: {error}"));
|
|
let mut exported = std::fs::read_to_string(&export_path)
|
|
.unwrap_or_else(|error| panic!("Base58 export must be readable: {error}"));
|
|
assert_eq!(exported, base58_private_key);
|
|
exported.zeroize();
|
|
base58_private_key.zeroize();
|
|
keypair_bytes.zeroize();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_migration_is_non_destructive_and_refuses_second_destination() {
|
|
let directory = tempfile::tempdir()
|
|
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
|
make_directory_private(directory.path());
|
|
let manager = ks_wallet::WalletManager::new(directory.path())
|
|
.unwrap_or_else(|error| panic!("manager must be created: {error}"));
|
|
let keypair = solana_keypair::Keypair::new();
|
|
let expected_public_key = keypair.pubkey().to_string();
|
|
let mut keypair_bytes = keypair.to_bytes();
|
|
let mut legacy_json = serde_json::to_vec(keypair_bytes.as_slice())
|
|
.unwrap_or_else(|error| panic!("legacy fixture must encode: {error}"));
|
|
let source_path = directory.path().join("legacy-migrate.json");
|
|
write_private_file(&source_path, legacy_json.as_slice());
|
|
let mut original_source = std::fs::read(&source_path)
|
|
.unwrap_or_else(|error| panic!("legacy fixture must be readable: {error}"));
|
|
let migrated = manager
|
|
.migrate_legacy(alias("legacy-migrate"), password("migration-password"))
|
|
.await
|
|
.unwrap_or_else(|error| panic!("legacy migration must succeed: {error}"));
|
|
assert_eq!(migrated.public_key(), expected_public_key);
|
|
migrated.lock();
|
|
let mut source_after = std::fs::read(&source_path)
|
|
.unwrap_or_else(|error| panic!("legacy source must remain readable: {error}"));
|
|
assert_eq!(source_after, original_source);
|
|
let error = manager
|
|
.migrate_legacy(alias("legacy-migrate"), password("second-migration-password"))
|
|
.await
|
|
.err()
|
|
.unwrap_or_else(|| panic!("second migration must refuse the native alias collision"));
|
|
assert_eq!(error.code(), "wallet_native_already_exists");
|
|
let mut source_after_error = std::fs::read(&source_path)
|
|
.unwrap_or_else(|read_error| panic!("legacy source must survive collision: {read_error}"));
|
|
assert_eq!(source_after_error, original_source);
|
|
source_after_error.zeroize();
|
|
source_after.zeroize();
|
|
original_source.zeroize();
|
|
legacy_json.zeroize();
|
|
keypair_bytes.zeroize();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_rejects_duplicate_pubkey_and_export_requires_valid_password() {
|
|
let directory = tempfile::tempdir()
|
|
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
|
make_directory_private(directory.path());
|
|
let manager = ks_wallet::WalletManager::new(directory.path())
|
|
.unwrap_or_else(|error| panic!("manager must be created: {error}"));
|
|
let keypair = solana_keypair::Keypair::new();
|
|
let mut keypair_bytes = keypair.to_bytes();
|
|
let mut source_json = serde_json::to_vec(keypair_bytes.as_slice())
|
|
.unwrap_or_else(|error| panic!("duplicate fixture must encode: {error}"));
|
|
let source_path = directory.path().join("duplicate-source.json");
|
|
write_private_file(&source_path, source_json.as_slice());
|
|
source_json.zeroize();
|
|
let first = manager
|
|
.import_file(
|
|
alias("duplicate-a"),
|
|
password("duplicate-password"),
|
|
&source_path,
|
|
ks_wallet::WalletTransferFormat::SolanaCliJson,
|
|
)
|
|
.await
|
|
.unwrap_or_else(|error| panic!("first import must succeed: {error}"));
|
|
first.lock();
|
|
let duplicate_error = manager
|
|
.import_file(
|
|
alias("duplicate-b"),
|
|
password("unused-duplicate-password"),
|
|
&source_path,
|
|
ks_wallet::WalletTransferFormat::SolanaCliJson,
|
|
)
|
|
.await
|
|
.err()
|
|
.unwrap_or_else(|| panic!("duplicate public key must be rejected"));
|
|
assert_eq!(duplicate_error.code(), "wallet_public_key_already_exists");
|
|
let export_path = directory.path().join("wrong-password-export.json");
|
|
let password_error = manager
|
|
.export_file(
|
|
&alias("duplicate-a"),
|
|
password("wrong-export-password"),
|
|
&export_path,
|
|
ks_wallet::WalletTransferFormat::SolanaCliJson,
|
|
)
|
|
.await
|
|
.err()
|
|
.unwrap_or_else(|| panic!("wrong export password must fail"));
|
|
assert_eq!(password_error.code(), "wallet_native_authentication_failed");
|
|
assert!(!export_path.exists());
|
|
keypair_bytes.zeroize();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_rejects_invalid_sources_without_creating_native_destination() {
|
|
let directory = tempfile::tempdir()
|
|
.unwrap_or_else(|error| panic!("temporary directory must exist: {error}"));
|
|
make_directory_private(directory.path());
|
|
let manager = ks_wallet::WalletManager::new(directory.path())
|
|
.unwrap_or_else(|error| panic!("manager must be created: {error}"));
|
|
let invalid_json_path = directory.path().join("invalid-source.json");
|
|
write_private_file(&invalid_json_path, b"[1,2,3]");
|
|
let json_error = manager
|
|
.import_file(
|
|
alias("invalid-json"),
|
|
password("invalid-json-password"),
|
|
&invalid_json_path,
|
|
ks_wallet::WalletTransferFormat::SolanaCliJson,
|
|
)
|
|
.await
|
|
.err()
|
|
.unwrap_or_else(|| panic!("short Solana JSON import must fail"));
|
|
assert_eq!(json_error.code(), "wallet_import_keypair_length_invalid");
|
|
assert!(!directory.path().join("invalid-json.kswallet").exists());
|
|
let invalid_base58_path = directory.path().join("invalid-base58.txt");
|
|
write_private_file(&invalid_base58_path, b"not valid base58 !!!");
|
|
let base58_error = manager
|
|
.import_file(
|
|
alias("invalid-base58"),
|
|
password("invalid-base58-password"),
|
|
&invalid_base58_path,
|
|
ks_wallet::WalletTransferFormat::SolanaPrivateKeyBase58,
|
|
)
|
|
.await
|
|
.err()
|
|
.unwrap_or_else(|| panic!("invalid Base58 import must fail"));
|
|
assert_eq!(base58_error.code(), "wallet_import_base58_invalid");
|
|
assert!(!directory.path().join("invalid-base58.kswallet").exists());
|
|
}
|