// file: ks-wallet/tests/transfer.rs // version: 2 //! 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::>(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()); } #[tokio::test] async fn transfer_inspection_exposes_only_public_identity_for_supported_formats() { let directory = tempfile::tempdir() .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); make_directory_private(directory.path()); let keypair = solana_keypair::Keypair::new(); let expected_public_key = keypair.pubkey().to_string(); let mut keypair_bytes = keypair.to_bytes(); let mut json = serde_json::to_vec(keypair_bytes.as_slice()) .unwrap_or_else(|error| panic!("Solana JSON fixture must encode: {error}")); let json_path = directory.path().join("inspect.json"); write_private_file(&json_path, json.as_slice()); let json_inspection = ks_wallet::inspect_transfer_file( &json_path, ks_wallet::WalletTransferFormat::SolanaCliJson, ) .await .unwrap_or_else(|error| panic!("Solana JSON inspection must succeed: {error}")); assert_eq!(json_inspection.public_key(), expected_public_key); assert_eq!(json_inspection.format().code(), "solana_cli_json"); json.zeroize(); let mut base58 = bs58::encode(keypair_bytes.as_slice()).into_string(); let base58_path = directory.path().join("inspect.txt"); write_private_file(&base58_path, base58.as_bytes()); let base58_inspection = ks_wallet::inspect_transfer_file( &base58_path, ks_wallet::WalletTransferFormat::SolanaPrivateKeyBase58, ) .await .unwrap_or_else(|error| panic!("Base58 inspection must succeed: {error}")); assert_eq!(base58_inspection.public_key(), expected_public_key); assert_eq!(base58_inspection.format().code(), "solana_private_key_base58"); let formats = ks_wallet::WalletTransferFormat::supported(); assert_eq!(formats.len(), 2); assert_eq!(formats[0].default_extension(), "json"); assert_eq!(formats[1].default_extension(), "txt"); base58.zeroize(); keypair_bytes.zeroize(); }