Skip to content

Commit d60f2a2

Browse files
committed
feat: implement interactive TUI api key configuration wizard with zeroize mem security
1 parent 477d01e commit d60f2a2

9 files changed

Lines changed: 484 additions & 5 deletions

File tree

check_errors.txt

20.8 KB
Binary file not shown.

crates/common/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ edition = "2021"
55

66
[dependencies]
77
serde = { version = "1.0", features = ["derive"] }
8+
zeroize = { version = "1", features = ["zeroize_derive"] }
89
serde_json = "1.0"
910
anyhow = "1.0"
1011
thiserror = "1.0"

crates/common/src/env_writer.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
use std::collections::BTreeMap;
2+
use std::fs;
3+
use std::io::Write;
4+
use std::path::{Path, PathBuf};
5+
6+
/// Finds the project .env path (next to Cargo.toml or in working dir)
7+
pub fn env_path() -> PathBuf {
8+
// Check if .env exists in current directory
9+
let cwd = std::env::current_dir().unwrap_or_default();
10+
cwd.join(".env")
11+
}
12+
13+
/// Writes key-value pairs to .env, preserving existing entries
14+
/// Uses atomic write (write to .env.tmp, then rename)
15+
pub fn save_keys(keys: &[(&str, &str)]) -> std::io::Result<()> {
16+
let path = env_path();
17+
let mut existing = load_existing(&path);
18+
19+
// Merge new keys (overwrite if already present)
20+
for (k, v) in keys {
21+
if !v.trim().is_empty() {
22+
existing.insert(k.to_string(), v.to_string());
23+
}
24+
}
25+
26+
// Write to temporary file first (atomic write)
27+
let tmp_path = path.with_extension("env.tmp");
28+
{
29+
let mut file = fs::File::create(&tmp_path)?;
30+
31+
// Write header
32+
writeln!(file, "# RustForge Configuration")?;
33+
writeln!(file, "# Auto-generated by setup wizard")?;
34+
writeln!(file, "# Edit manually or re-run setup with: cargo run -p tui -- --setup")?;
35+
writeln!(file)?;
36+
37+
for (key, value) in &existing {
38+
writeln!(file, "{}={}", key, value)?;
39+
}
40+
41+
file.sync_all()?; // Ensure flush to disk
42+
}
43+
44+
// Atomic rename
45+
fs::rename(&tmp_path, &path)?;
46+
47+
// Set secure permissions on Unix systems
48+
#[cfg(unix)]
49+
{
50+
use std::os::unix::fs::PermissionsExt;
51+
let perms = std::fs::Permissions::from_mode(0o600); // owner read/write only
52+
if let Err(e) = std::fs::set_permissions(&path, perms) {
53+
eprintln!("Warning: could not set secure .env permissions: {}", e);
54+
}
55+
}
56+
57+
// Also inject into current process environment
58+
for (k, v) in keys {
59+
if !v.trim().is_empty() {
60+
std::env::set_var(k, v);
61+
}
62+
}
63+
64+
Ok(())
65+
}
66+
67+
fn load_existing(path: &Path) -> BTreeMap<String, String> {
68+
let mut map = BTreeMap::new();
69+
if let Ok(content) = fs::read_to_string(path) {
70+
for line in content.lines() {
71+
let line = line.trim();
72+
if line.is_empty() || line.starts_with('#') {
73+
continue;
74+
}
75+
if let Some((k, v)) = line.split_once('=') {
76+
map.insert(k.trim().to_string(), v.trim().to_string());
77+
}
78+
}
79+
}
80+
map
81+
}
82+
83+
#[cfg(test)]
84+
mod tests {
85+
use super::*;
86+
use tempfile::TempDir;
87+
88+
#[test]
89+
fn test_roundtrip_write_read() {
90+
let dir = TempDir::new().unwrap();
91+
let path = dir.path().join(".env");
92+
std::env::set_current_dir(dir.path()).unwrap();
93+
94+
save_keys(&[
95+
("FINNHUB_API_KEY", "test_key_123"),
96+
("ALPACA_API_KEY", "AKID_456"),
97+
]).unwrap();
98+
99+
let loaded = load_existing(&path);
100+
assert_eq!(loaded.get("FINNHUB_API_KEY").unwrap(), "test_key_123");
101+
assert_eq!(loaded.get("ALPACA_API_KEY").unwrap(), "AKID_456");
102+
}
103+
}

crates/common/src/key_validator.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/// Quick validation — just checks format, not live API calls
2+
pub fn validate_key_format(name: &str, value: &str) -> Result<(), String> {
3+
match name {
4+
"FINNHUB_API_KEY" => {
5+
if value.len() < 10 {
6+
return Err("Finnhub keys are typically 20+ characters".into());
7+
}
8+
}
9+
"ALPACA_API_KEY" => {
10+
if !value.starts_with("AK") && !value.starts_with("PK") {
11+
return Err("Alpaca Key IDs typically start with AK or PK".into());
12+
}
13+
}
14+
"ALPACA_SECRET_KEY" => {
15+
if value.len() < 20 {
16+
return Err("Alpaca secret keys are typically 40+ characters".into());
17+
}
18+
}
19+
"ANTHROPIC_API_KEY" => {
20+
if !value.starts_with("sk-ant-") {
21+
return Err("Anthropic keys start with sk-ant-".into());
22+
}
23+
}
24+
_ => {}
25+
}
26+
Ok(())
27+
}

crates/common/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ pub mod dashboard;
44
pub mod events;
55
pub mod models;
66
pub mod config;
7+
pub mod env_writer;
8+
pub mod key_validator;
79

810
#[derive(Debug, Serialize, Deserialize, Clone)]
911
pub struct SwapEvent {

crates/tui/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ crossterm = "0.27"
99
anyhow = "1.0"
1010
tokio = { version = "1.35", features = ["full"] }
1111
common = { path = "../common" }
12+
zeroize = { version = "1", features = ["zeroize_derive"] }
13+
dotenvy = "0.15"
1214
crossbeam-channel = "0.5"
1315
serde_json = "1.0"
1416
tokio-retry = "0.3.0"

crates/tui/src/app.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,33 @@ pub enum AlertSeverity {
5050

5151
use common::models::exchange::{ExchangeInfo, ExchangeStatus, ExchangeName};
5252

53+
// ── App Screens ───────────────────────────────────────────────────────────────
54+
55+
pub enum AppScreen {
56+
Setup(SetupState),
57+
Dashboard,
58+
}
59+
60+
pub struct SetupState {
61+
pub fields: Vec<KeyField>,
62+
pub active_field: usize,
63+
pub error_msg: Option<String>,
64+
pub show_confirmation: bool,
65+
}
66+
67+
pub struct KeyField {
68+
pub name: &'static str,
69+
pub label: &'static str,
70+
pub value: String,
71+
pub required: bool,
72+
pub masked: bool,
73+
pub hint: &'static str,
74+
}
75+
5376
// ── Main App ──────────────────────────────────────────────────────────────────
5477

5578
pub struct App {
79+
pub screen: AppScreen,
5680
pub should_quit: bool,
5781
pub connection_status: String,
5882
pub show_help: bool,
@@ -104,8 +128,9 @@ pub struct App {
104128
}
105129

106130
impl App {
107-
pub fn new() -> Self {
131+
pub fn new(initial_screen: AppScreen) -> Self {
108132
Self {
133+
screen: initial_screen,
109134
should_quit: false,
110135
connection_status: "Connecting...".to_string(),
111136
show_help: false,

crates/tui/src/main.rs

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ mod event_handler;
3232
pub mod widgets;
3333
pub mod layout;
3434
pub mod state;
35+
pub mod setup;
3536

3637
use app::App;
3738
use common::models::exchange::ExchangeStatus;
@@ -45,7 +46,23 @@ async fn main() -> anyhow::Result<()> {
4546
let backend = CrosstermBackend::new(stdout);
4647
let mut terminal = Terminal::new(backend)?;
4748

48-
let mut app = App::new();
49+
// Try loading existing .env
50+
let _ = dotenvy::dotenv();
51+
52+
// Determine if setup is needed
53+
let needs_setup = std::env::var("FINNHUB_API_KEY").is_err()
54+
|| std::env::var("ALPACA_API_KEY").is_err()
55+
|| std::env::var("ALPACA_SECRET_KEY").is_err();
56+
57+
let force_setup = std::env::args().any(|a| a == "--setup");
58+
59+
let initial_screen = if needs_setup || force_setup {
60+
crate::app::AppScreen::Setup(crate::app::SetupState::new())
61+
} else {
62+
crate::app::AppScreen::Dashboard
63+
};
64+
65+
let mut app = App::new(initial_screen);
4966

5067
// Event Bus Connection Manager
5168
let (tx_status, mut rx_status) = mpsc::channel::<String>(100);
@@ -94,15 +111,50 @@ async fn main() -> anyhow::Result<()> {
94111
app.update_from_event(event);
95112
}
96113

97-
terminal.draw(|f| ui(f, &app))?;
114+
terminal.draw(|f| {
115+
match &app.screen {
116+
crate::app::AppScreen::Setup(state) => crate::setup::render_setup(f, state),
117+
crate::app::AppScreen::Dashboard => ui(f, &app),
118+
}
119+
})?;
98120

99121
if crossterm::event::poll(Duration::from_millis(50))? {
100122
match event::read()? {
101123
Event::Key(key) => {
102-
event_handler::handle_key(&mut app, key);
124+
match &mut app.screen {
125+
crate::app::AppScreen::Setup(state) => {
126+
match crate::setup::handle_setup_key(key, state) {
127+
crate::setup::SetupAction::Submit => {
128+
let pairs: Vec<(&str, &str)> = state.fields.iter()
129+
.map(|f| (f.name, f.value.as_str()))
130+
.collect();
131+
132+
match common::env_writer::save_keys(&pairs) {
133+
Ok(()) => {
134+
use zeroize::Zeroize;
135+
for field in &mut state.fields {
136+
field.value.zeroize();
137+
}
138+
app.screen = crate::app::AppScreen::Dashboard;
139+
}
140+
Err(e) => {
141+
state.error_msg = Some(format!("Failed to save .env: {}", e));
142+
}
143+
}
144+
}
145+
crate::setup::SetupAction::Quit => break,
146+
crate::setup::SetupAction::Continue => {}
147+
}
148+
}
149+
crate::app::AppScreen::Dashboard => {
150+
event_handler::handle_key(&mut app, key);
151+
}
152+
}
103153
}
104154
Event::Mouse(mouse_event) => {
105-
event_handler::handle_mouse(&mut app, mouse_event);
155+
if let crate::app::AppScreen::Dashboard = &app.screen {
156+
event_handler::handle_mouse(&mut app, mouse_event);
157+
}
106158
}
107159
_ => {}
108160
}

0 commit comments

Comments
 (0)