Skip to content

Commit d19dc5e

Browse files
committed
Bump version to 0.16.0
1 parent a17a123 commit d19dc5e

File tree

5 files changed

+159
-20
lines changed

5 files changed

+159
-20
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "sshdb"
3-
version = "0.15.0"
3+
version = "0.16.0"
44
edition = "2021"
55
description = "Keyboard-first SSH library and launcher TUI."
66
authors = ["Riccardo Iaconelli <[email protected]>"]

src/app.rs

Lines changed: 122 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
use std::path::PathBuf;
55

6-
use anyhow::{anyhow, Context, Result};
6+
use anyhow::{anyhow, bail, Context, Result};
77
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
88
use fuzzy_matcher::skim::SkimMatcherV2;
99
use 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

5354
impl 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

101113
impl 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
}

src/ssh.rs

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
// SPDX-License-Identifier: GPL-3.0-or-later
22
// SPDX-FileCopyrightText: 2024 Riccardo Iaconelli <[email protected]>
33

4-
use std::path::PathBuf;
4+
use std::path::{Path, PathBuf};
55
use std::process::{Command, Stdio};
66

7-
use anyhow::{Context, Result};
7+
use anyhow::Result;
88

99
use crate::model::{Config, Host};
1010

@@ -121,9 +121,9 @@ fn build_bastion_string(
121121
}
122122
visited.push(bastion_name.to_string());
123123

124-
let bastion = config
125-
.find_host(bastion_name)
126-
.with_context(|| format!("bastion host '{}' not found", bastion_name))?;
124+
let Some(bastion) = config.find_host(bastion_name) else {
125+
return Ok(bastion_name.to_string());
126+
};
127127

128128
let mut chains = Vec::new();
129129
if let Some(nested) = &bastion.bastion {
@@ -167,11 +167,14 @@ fn select_key(host_key: Option<&str>, default_key: Option<&str>) -> Option<Strin
167167
return None;
168168
}
169169

170-
// fall back to common keys when no agent is present
171-
if let Some(cand) = FALLBACKS.first() {
172-
return Some(expand_tilde(cand));
170+
// fall back to common keys when no agent is present; prefer an existing one
171+
for cand in FALLBACKS {
172+
let expanded = expand_tilde(cand);
173+
if Path::new(&expanded).exists() {
174+
return Some(expanded);
175+
}
173176
}
174-
None
177+
FALLBACKS.first().map(|cand| expand_tilde(cand))
175178
}
176179

177180
fn expand_tilde(path: &str) -> String {
@@ -216,6 +219,27 @@ mod tests {
216219
assert!(preview.contains("-L 8080:localhost:80"));
217220
}
218221

222+
#[test]
223+
fn allows_free_text_bastion() {
224+
let mut config = Config::default();
225+
let host = Host {
226+
name: "prod".into(),
227+
address: "10.0.0.1".into(),
228+
user: Some("deploy".into()),
229+
port: None,
230+
key_path: None,
231+
tags: vec![],
232+
options: vec![],
233+
remote_command: None,
234+
description: None,
235+
bastion: Some("proxy.example.com".into()),
236+
};
237+
config.hosts.push(host.clone());
238+
let preview = command_preview(&host, &config, None, None);
239+
assert!(preview.contains("-J proxy.example.com"));
240+
assert!(preview.contains("[email protected]"));
241+
}
242+
219243
#[test]
220244
fn expands_tilde() {
221245
let out = expand_tilde("~/abc");

sshdb.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
class Sshdb < Formula
22
desc "Keyboard-first SSH library and launcher TUI"
33
homepage "https://github.com/ruphy/sshdb"
4-
url "https://github.com/ruphy/sshdb/archive/refs/tags/v0.15.0.tar.gz"
5-
sha256 "f0fed6beb31bc95fd75b7ed9e1dd0cd11a5588e3934b27d8b469049c91a27e57"
4+
url "https://github.com/ruphy/sshdb/archive/refs/tags/v0.16.0.tar.gz"
5+
sha256 "PLACEHOLDER_SHA256_WILL_BE_UPDATED_AFTER_RELEASE"
66
license "GPL-3.0-or-later"
77
depends_on "rust" => :build
88

0 commit comments

Comments
 (0)