Skip to main content

solvela_client_cli_args/
lib.rs

1//! Shared CLI argument structs and wallet loading for `SolvelaClient` binaries.
2//!
3//! Provides reusable [`clap::Args`] groups (`WalletArgs`, `GatewayArgs`, `RpcArgs`)
4//! and helper functions for wallet file I/O so that the proxy and CLI binaries
5//! share identical wallet-loading logic.
6
7use std::path::PathBuf;
8
9use clap::Args;
10
11use solvela_client::Wallet;
12
13/// CLI arguments for wallet configuration.
14#[derive(Debug, Clone, Args)]
15pub struct WalletArgs {
16    /// Environment variable containing a base58-encoded keypair.
17    #[arg(long, default_value = "SOLVELA_WALLET_KEY")]
18    pub wallet_env: String,
19
20    /// Path to wallet keypair file (Solana CLI JSON byte-array format).
21    #[arg(long, default_value = "~/.solvela/wallet.json")]
22    pub wallet_file: String,
23}
24
25/// CLI arguments for gateway connection.
26#[derive(Debug, Clone, Args)]
27pub struct GatewayArgs {
28    /// Gateway URL to forward requests to.
29    #[arg(short = 'g', long, default_value = "https://api.solvela.ai")]
30    pub gateway: String,
31}
32
33/// CLI arguments for Solana RPC connection.
34#[derive(Debug, Clone, Args)]
35pub struct RpcArgs {
36    /// Solana RPC URL.
37    #[arg(long, default_value = "https://api.mainnet-beta.solana.com")]
38    pub rpc_url: String,
39}
40
41/// Expand a leading `~` in a path to the user's home directory.
42///
43/// If the path does not start with `~/`, it is returned as-is.
44#[must_use]
45pub fn expand_home(path: &str) -> PathBuf {
46    if let Some(rest) = path.strip_prefix("~/") {
47        match dirs_next::home_dir() {
48            Some(home) => home.join(rest),
49            None => PathBuf::from(path),
50        }
51    } else {
52        PathBuf::from(path)
53    }
54}
55
56/// Load a wallet from an environment variable (priority) or file (fallback).
57///
58/// The env var is expected to contain a base58-encoded 64-byte keypair.
59/// The file is expected to be in Solana CLI JSON byte-array format (`[174, 47, ...]`).
60///
61/// # Errors
62///
63/// Returns a human-readable error string if neither source yields a valid wallet.
64pub fn load_wallet(args: &WalletArgs) -> Result<Wallet, String> {
65    // Try env var first
66    if let Ok(val) = std::env::var(&args.wallet_env) {
67        if !val.is_empty() {
68            return Wallet::from_keypair_b58(&val)
69                .map_err(|e| format!("invalid keypair in {}: {e}", args.wallet_env));
70        }
71    }
72
73    // Expand ~ to home directory
74    let expanded = expand_home(&args.wallet_file);
75
76    // Try wallet file
77    if expanded.exists() {
78        // Warn if wallet file has insecure permissions (private key exposure risk)
79        #[cfg(unix)]
80        {
81            use std::os::unix::fs::MetadataExt;
82            if let Ok(meta) = expanded.metadata() {
83                if meta.mode() & 0o077 != 0 {
84                    tracing::warn!(
85                        path = %expanded.display(),
86                        "wallet file has insecure permissions (should be 0600)"
87                    );
88                }
89            }
90        }
91
92        let contents = std::fs::read_to_string(&expanded)
93            .map_err(|e| format!("failed to read {}: {e}", expanded.display()))?;
94
95        // Parse Solana CLI format: JSON array of u8 values [174, 47, ...]
96        let bytes: Vec<u8> = serde_json::from_str(&contents)
97            .map_err(|e| format!("invalid wallet file format in {}: {e}", expanded.display()))?;
98
99        return Wallet::from_keypair_bytes(&bytes)
100            .map_err(|e| format!("invalid keypair in {}: {e}", expanded.display()));
101    }
102
103    Err(format!(
104        "no wallet found: set {} env var or create {}",
105        args.wallet_env,
106        expanded.display()
107    ))
108}
109
110/// Save raw keypair bytes to a file in Solana CLI JSON byte-array format.
111///
112/// The file is written with `0o600` permissions (owner read/write only).
113/// Refuses to overwrite an existing file unless `force` is `true`.
114///
115/// # Errors
116///
117/// Returns a human-readable error string if the file cannot be written or
118/// already exists without `force`.
119pub fn save_wallet(path: &str, keypair_bytes: &[u8], force: bool) -> Result<PathBuf, String> {
120    let expanded = expand_home(path);
121
122    if expanded.exists() && !force {
123        return Err(format!(
124            "wallet file already exists at {} (use --force to overwrite)",
125            expanded.display()
126        ));
127    }
128
129    // Ensure parent directory exists
130    if let Some(parent) = expanded.parent() {
131        std::fs::create_dir_all(parent)
132            .map_err(|e| format!("failed to create directory {}: {e}", parent.display()))?;
133    }
134
135    // Serialize as JSON byte array (Solana CLI format)
136    let json = serde_json::to_string(&keypair_bytes)
137        .map_err(|e| format!("failed to serialize keypair: {e}"))?;
138
139    // Write with restricted permissions from the start (0o600) to avoid
140    // a window where the file is world-readable before chmod.
141    #[cfg(unix)]
142    {
143        use std::io::Write;
144        use std::os::unix::fs::OpenOptionsExt;
145        let mut file = std::fs::OpenOptions::new()
146            .write(true)
147            .create(true)
148            .truncate(true)
149            .mode(0o600)
150            .open(&expanded)
151            .map_err(|e| format!("failed to create {}: {e}", expanded.display()))?;
152        file.write_all(json.as_bytes())
153            .map_err(|e| format!("failed to write {}: {e}", expanded.display()))?;
154    }
155
156    #[cfg(not(unix))]
157    {
158        // MEDIUM-3: on non-Unix (Windows in particular) we cannot enforce
159        // 0600-equivalent ACLs without pulling in extra dependencies. Warn
160        // loudly so operators know the wallet file is created with whatever
161        // default ACL the platform applies (typically inherited from the
162        // parent directory and potentially world-readable on shared hosts).
163        // TODO: implement proper Windows ACL hardening via the
164        // `windows-permissions` crate or `OpenOptionsExt::access_mode` so
165        // the file is restricted to the current user only. See audit
166        // finding MEDIUM-3.
167        std::fs::write(&expanded, &json)
168            .map_err(|e| format!("failed to write {}: {e}", expanded.display()))?;
169        tracing::warn!(
170            path = %expanded.display(),
171            "wallet file created without owner-only ACL on non-Unix platform; \
172             treat the file location as sensitive (MEDIUM-3)"
173        );
174    }
175
176    Ok(expanded)
177}
178
179#[cfg(test)]
180mod tests {
181    use solana_sdk::signer::Signer;
182
183    use super::*;
184
185    #[test]
186    fn test_expand_home_with_tilde() {
187        let result = expand_home("~/some/path");
188        // Should NOT start with ~ (it should be expanded)
189        assert!(
190            !result.to_string_lossy().starts_with('~'),
191            "path was not expanded: {result:?}"
192        );
193        assert!(
194            result.to_string_lossy().ends_with("some/path"),
195            "path suffix missing: {result:?}"
196        );
197    }
198
199    #[test]
200    fn test_expand_home_without_tilde() {
201        let result = expand_home("/absolute/path");
202        assert_eq!(result, PathBuf::from("/absolute/path"));
203
204        let relative = expand_home("relative/path");
205        assert_eq!(relative, PathBuf::from("relative/path"));
206    }
207
208    #[test]
209    fn test_load_wallet_from_env() {
210        // Generate a fresh keypair and encode as base58
211        let kp = solana_sdk::signer::keypair::Keypair::new();
212        let b58 = bs58::encode(kp.to_bytes()).into_string();
213        let expected_addr = kp.pubkey().to_string();
214
215        // Use a unique env var name to avoid conflicts with parallel tests
216        let env_var = "SOLVELA_TEST_WALLET_LOAD_ENV_7291";
217        std::env::set_var(env_var, &b58);
218
219        let args = WalletArgs {
220            wallet_env: env_var.to_string(),
221            wallet_file: "/nonexistent/path.json".to_string(),
222        };
223
224        let wallet = load_wallet(&args).expect("should load from env");
225        assert_eq!(wallet.address(), expected_addr);
226
227        // Clean up
228        std::env::remove_var(env_var);
229    }
230
231    #[test]
232    fn test_load_wallet_no_source() {
233        // Ensure env var is not set
234        let env_var = "SOLVELA_TEST_WALLET_NOSOURCE_4821";
235        std::env::remove_var(env_var);
236
237        let args = WalletArgs {
238            wallet_env: env_var.to_string(),
239            wallet_file: "/nonexistent/wallet_file_that_does_not_exist.json".to_string(),
240        };
241
242        let result = load_wallet(&args);
243        assert!(result.is_err());
244        let err = result.unwrap_err();
245        assert!(
246            err.contains("no wallet found"),
247            "unexpected error message: {err}"
248        );
249    }
250
251    #[test]
252    fn test_save_wallet_creates_file() {
253        let dir = std::env::temp_dir().join("solvela_test_save_wallet");
254        let _ = std::fs::remove_dir_all(&dir);
255        std::fs::create_dir_all(&dir).unwrap();
256
257        let kp = solana_sdk::signer::keypair::Keypair::new();
258        let bytes = kp.to_bytes();
259        let file_path = dir.join("test_wallet.json");
260        let path_str = file_path.to_string_lossy().to_string();
261
262        let result = save_wallet(&path_str, &bytes, false);
263        assert!(result.is_ok(), "save_wallet failed: {result:?}");
264
265        let saved_path = result.unwrap();
266        assert!(saved_path.exists());
267
268        // Verify the saved content is valid Solana CLI format
269        let contents = std::fs::read_to_string(&saved_path).unwrap();
270        let parsed: Vec<u8> = serde_json::from_str(&contents).unwrap();
271        assert_eq!(parsed.len(), 64);
272        assert_eq!(&parsed[..], &bytes[..]);
273
274        // Verify permissions on Unix
275        #[cfg(unix)]
276        {
277            use std::os::unix::fs::MetadataExt;
278            let meta = saved_path.metadata().unwrap();
279            assert_eq!(meta.mode() & 0o777, 0o600, "permissions should be 0600");
280        }
281
282        // Clean up
283        let _ = std::fs::remove_dir_all(&dir);
284    }
285
286    #[test]
287    fn test_save_wallet_refuses_overwrite() {
288        let dir = std::env::temp_dir().join("solvela_test_save_overwrite");
289        let _ = std::fs::remove_dir_all(&dir);
290        std::fs::create_dir_all(&dir).unwrap();
291
292        let kp = solana_sdk::signer::keypair::Keypair::new();
293        let bytes = kp.to_bytes();
294        let file_path = dir.join("existing_wallet.json");
295        let path_str = file_path.to_string_lossy().to_string();
296
297        // Create the file first
298        save_wallet(&path_str, &bytes, false).unwrap();
299
300        // Attempt to overwrite without force — should fail
301        let result = save_wallet(&path_str, &bytes, false);
302        assert!(result.is_err());
303        let err = result.unwrap_err();
304        assert!(err.contains("already exists"), "unexpected error: {err}");
305
306        // With force — should succeed
307        let result = save_wallet(&path_str, &bytes, true);
308        assert!(result.is_ok(), "force overwrite failed: {result:?}");
309
310        // Clean up
311        let _ = std::fs::remove_dir_all(&dir);
312    }
313}