33
44use std:: path:: PathBuf ;
55
6- use anyhow:: { anyhow, Context , Result } ;
6+ use anyhow:: { anyhow, bail , Context , Result } ;
77use crossterm:: event:: { Event , KeyCode , KeyEvent , KeyEventKind , KeyModifiers } ;
88use fuzzy_matcher:: skim:: SkimMatcherV2 ;
99use fuzzy_matcher:: FuzzyMatcher ;
@@ -48,14 +48,16 @@ pub struct BastionDropdownState {
4848 pub search_filter : String ,
4949 pub filtered_indices : Vec < usize > ,
5050 pub selected : usize ,
51+ exclude_host : Option < String > ,
5152}
5253
5354impl BastionDropdownState {
54- pub fn new ( config : & Config ) -> Self {
55+ pub fn new ( config : & Config , exclude_host : Option < & str > ) -> Self {
5556 let mut state = Self {
5657 search_filter : String :: new ( ) ,
5758 filtered_indices : Vec :: new ( ) ,
5859 selected : 0 ,
60+ exclude_host : exclude_host. map ( |s| s. to_string ( ) ) ,
5961 } ;
6062 state. rebuild_filter ( config) ;
6163 state
@@ -64,10 +66,19 @@ impl BastionDropdownState {
6466 pub fn rebuild_filter ( & mut self , config : & Config ) {
6567 let matcher = SkimMatcherV2 :: default ( ) ;
6668 if self . search_filter . is_empty ( ) {
67- self . filtered_indices = ( 0 ..config. hosts . len ( ) ) . collect ( ) ;
69+ self . filtered_indices = config
70+ . hosts
71+ . iter ( )
72+ . enumerate ( )
73+ . filter ( |( _, h) | self . exclude_host . as_deref ( ) != Some ( & h. name ) )
74+ . map ( |( i, _) | i)
75+ . collect ( ) ;
6876 } else {
6977 let mut scored: Vec < ( i64 , usize ) > = Vec :: new ( ) ;
7078 for ( i, host) in config. hosts . iter ( ) . enumerate ( ) {
79+ if self . exclude_host . as_deref ( ) == Some ( & host. name ) {
80+ continue ;
81+ }
7182 let haystack = format ! (
7283 "{} {} {} {}" ,
7384 host. name,
@@ -96,6 +107,7 @@ pub struct FormState {
96107 pub fields : Vec < FormField > ,
97108 pub index : usize ,
98109 pub bastion_dropdown : Option < BastionDropdownState > ,
110+ editing_host_name : Option < String > ,
99111}
100112
101113impl FormState {
@@ -206,6 +218,7 @@ impl FormState {
206218 fields,
207219 index : 0 ,
208220 bastion_dropdown : None ,
221+ editing_host_name : host. map ( |h| h. name . clone ( ) ) ,
209222 }
210223 }
211224
@@ -453,7 +466,7 @@ impl FormState {
453466 } else {
454467 5
455468 } ;
456- let mut dropdown = BastionDropdownState :: new ( config) ;
469+ let mut dropdown = BastionDropdownState :: new ( config, self . editing_host_name . as_deref ( ) ) ;
457470 // Initialize search filter with current field value
458471 if let Some ( f) = self . fields . get ( bastion_field_idx) {
459472 dropdown. search_filter = f. value . clone ( ) ;
@@ -1028,9 +1041,18 @@ impl App {
10281041 match form. build_host ( ) {
10291042 Ok ( host) => {
10301043 let action = form. kind ;
1031- self . save_host ( action, host) ?;
1032- self . form = None ;
1033- self . mode = Mode :: Normal ;
1044+ match self . save_host ( action, host) {
1045+ Ok ( _) => {
1046+ self . form = None ;
1047+ self . mode = Mode :: Normal ;
1048+ }
1049+ Err ( e) => {
1050+ self . status = Some ( StatusLine {
1051+ text : e. to_string ( ) ,
1052+ kind : StatusKind :: Error ,
1053+ } ) ;
1054+ }
1055+ }
10341056 }
10351057 Err ( e) => {
10361058 self . status = Some ( StatusLine {
@@ -1190,6 +1212,23 @@ impl App {
11901212 }
11911213
11921214 fn save_host ( & mut self , kind : FormKind , host : Host ) -> Result < ( ) > {
1215+ let mut validation_config = self . config . clone ( ) ;
1216+ match kind {
1217+ FormKind :: Add => validation_config. hosts . push ( host. clone ( ) ) ,
1218+ FormKind :: Edit => {
1219+ if let Some ( idx) = self . current_index ( ) {
1220+ validation_config. hosts [ idx] = host. clone ( ) ;
1221+ } else {
1222+ self . status = Some ( StatusLine {
1223+ text : "No host selected to edit." . into ( ) ,
1224+ kind : StatusKind :: Warn ,
1225+ } ) ;
1226+ return Ok ( ( ) ) ;
1227+ }
1228+ }
1229+ }
1230+ Self :: validate_bastions ( & validation_config) ?;
1231+
11931232 match kind {
11941233 FormKind :: Add => {
11951234 self . push_history ( ) ;
@@ -1221,6 +1260,34 @@ impl App {
12211260 Ok ( ( ) )
12221261 }
12231262
1263+ fn validate_bastions ( config : & Config ) -> Result < ( ) > {
1264+ for host in & config. hosts {
1265+ if let Some ( bastion_name) = & host. bastion {
1266+ if bastion_name == & host. name {
1267+ bail ! ( "Host '{}' cannot use itself as bastion." , host. name) ;
1268+ }
1269+
1270+ let mut seen: Vec < String > = vec ! [ host. name. clone( ) ] ;
1271+ let mut current = bastion_name. as_str ( ) ;
1272+ loop {
1273+ if seen. iter ( ) . any ( |h| h == current) {
1274+ bail ! (
1275+ "Circular bastion reference detected involving '{}'." ,
1276+ current
1277+ ) ;
1278+ }
1279+ let Some ( bastion) = config. find_host ( current) else {
1280+ break ;
1281+ } ;
1282+ seen. push ( current. to_string ( ) ) ;
1283+ let Some ( next) = & bastion. bastion else { break } ;
1284+ current = next;
1285+ }
1286+ }
1287+ }
1288+ Ok ( ( ) )
1289+ }
1290+
12241291 fn current_index ( & self ) -> Option < usize > {
12251292 self . filtered_indices . get ( self . selected ) . cloned ( )
12261293 }
@@ -1518,6 +1585,41 @@ mod tests {
15181585 assert_eq ! ( spec. remote_command. as_deref( ) , Some ( "uptime" ) ) ;
15191586 }
15201587
1588+ #[ test]
1589+ fn rejects_self_bastion ( ) {
1590+ let app = test_app ( ) ;
1591+ let mut config = app. config . clone ( ) ;
1592+ if let Some ( host) = config. hosts . first_mut ( ) {
1593+ host. bastion = Some ( host. name . clone ( ) ) ;
1594+ }
1595+ let err = App :: validate_bastions ( & config) . unwrap_err ( ) ;
1596+ assert ! ( err. to_string( ) . contains( "cannot use itself as bastion" ) ) ;
1597+ }
1598+
1599+ #[ test]
1600+ fn rejects_circular_bastions ( ) {
1601+ let app = test_app ( ) ;
1602+ let mut config = app. config . clone ( ) ;
1603+ if let Some ( jump) = config. hosts . iter_mut ( ) . find ( |h| h. name == "jump-eu" ) {
1604+ jump. bastion = Some ( "staging-db" . into ( ) ) ;
1605+ }
1606+ let err = App :: validate_bastions ( & config) . unwrap_err ( ) ;
1607+ assert ! ( err
1608+ . to_string( )
1609+ . to_lowercase( )
1610+ . contains( "circular bastion reference" ) ) ;
1611+ }
1612+
1613+ #[ test]
1614+ fn allows_unknown_bastion_name ( ) {
1615+ let app = test_app ( ) ;
1616+ let mut config = app. config . clone ( ) ;
1617+ if let Some ( host) = config. hosts . first_mut ( ) {
1618+ host. bastion = Some ( "external.example.com" . into ( ) ) ;
1619+ }
1620+ App :: validate_bastions ( & config) . unwrap ( ) ;
1621+ }
1622+
15211623 #[ test]
15221624 fn quick_connect_adds_or_reuses ( ) {
15231625 let mut app = test_app ( ) ;
@@ -1531,4 +1633,17 @@ mod tests {
15311633 app. quick_connect ( spec) . unwrap ( ) ;
15321634 assert_eq ! ( app. config. hosts. len( ) , initial + 1 ) ;
15331635 }
1636+
1637+ #[ test]
1638+ fn bastion_dropdown_excludes_current_host ( ) {
1639+ let config = Config :: sample ( ) ;
1640+ let host = config. hosts [ 0 ] . clone ( ) ;
1641+ let mut form = FormState :: new ( FormKind :: Edit , Some ( & host) , & config) ;
1642+ form. open_bastion_dropdown ( & config) ;
1643+ let dropdown = form. bastion_dropdown . as_ref ( ) . expect ( "dropdown opened" ) ;
1644+ assert ! ( dropdown
1645+ . filtered_indices
1646+ . iter( )
1647+ . all( |i| config. hosts[ * i] . name != host. name) ) ;
1648+ }
15341649}
0 commit comments