Files
shell-rs/src/session/store.rs
T
Xuz ee488309f3 密钥凭据可粘贴和生成私钥,状态栏显示终端尺寸
- 密钥凭据的私钥分「本机文件 / 粘贴 / 生成新密钥」三种来源,仍是一个对话框;
  生成支持 Ed25519 与 RSA 4096,可用口令加密,对话框里显示并可复制公钥
- 粘贴和生成的私钥写成数据目录 keys/ 下的 0600 文件,凭据引用其路径;
  再次粘贴或生成时原地覆盖,换掉或删除凭据时随之删除,用户自己的私钥文件从不改动
- 空列表和空白处右键新增「生成密钥…」,密钥凭据右键新增「复制公钥」
- 窗口状态栏右侧由写死的光标位置改为当前终端的尺寸(列×行),
  SFTP 标签、开始页和设置页不显示
- UI 测试的基础夹具关闭动画,修正对话框滑入导致的偶发失败
2026-10-01 09:06:31 +08:00

2144 lines
80 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use gpui_kit::{Context, EventEmitter, SharedString};
use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore};
use super::database::now_seconds;
use super::private_key::{is_kept_in, write_key_file};
use super::{
AuthKind, BookmarkSide, ConnectionState, Credential, CredentialDraft, CredentialId,
CredentialKind, ForwardDraft, ForwardId, ForwardRule, GroupDraft, GroupId, HostOs, NodeDrop,
PublicId, Session, SessionDatabase, SessionDraft, SessionGroup, SessionId, SessionLogin,
SessionNode, StoredData,
};
/// The single source of truth for sessions, groups, the rules that belong
/// to sessions (bookmarks, port forwards) and the credentials sessions log in
/// with. Created once by the workspace and shared with every panel and
/// dialog; consumers observe it.
///
/// A session using a credential keeps a copy of the credential's user name,
/// so everything that shows or keys by `user@host:port` reads the session
/// alone. The store keeps the copy in step: it takes it from the credential
/// when a session is saved and rewrites it when the credential changes.
///
/// Mutators that take a `Context` notify observers and mirror the change into
/// the database; the `*_unnotified` variants are pure in-memory and exist for
/// tests, for loading, and for callers that batch several changes before one
/// notification.
///
/// Memory is authoritative. A failed write costs persistence, never the edit:
/// the change stands and a `SessionStoreEvent::PersistFailed` goes out so the
/// workspace can tell the user.
///
/// Passwords and private-key passphrases never reach the database. They go to
/// the system keychain through `secrets`, on a background thread, and report
/// failures through the same event.
pub struct SessionStore {
groups: Vec<SessionGroup>,
sessions: Vec<Session>,
next_session_id: u64,
next_group_id: u64,
active: Option<SessionId>,
/// Sessions in the order they last connected, most recent first.
recent: Vec<SessionId>,
/// SFTP bookmarks per session and pane, in the order they were added.
bookmarks: HashMap<(SessionId, BookmarkSide), Vec<String>>,
/// Port-forwarding rules, in the order the forward list shows them.
forwards: Vec<ForwardRule>,
next_forward_id: u64,
/// Credentials, in the order the credential list shows them.
credentials: Vec<Credential>,
next_credential_id: u64,
/// `None` for a memory-only store, as used by tests.
database: Option<SessionDatabase>,
/// Where passwords go. Defaults to a store that keeps nothing, so unit
/// tests never touch the machine's keychain.
secrets: SharedSecretStore,
/// Where the private keys pasted into or generated by the credential
/// form are kept. `None` without a database to remember them, which is
/// also how most tests run: the form then offers key files only.
key_dir: Option<PathBuf>,
}
/// How many sessions the start page lists as recently connected.
const MAX_RECENT: usize = 10;
/// What the store tells the workspace about, beyond plain change notification.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SessionStoreEvent {
/// A change was applied in memory but could not be written to disk.
PersistFailed(SharedString),
/// A live terminal must reconnect because the way the session logs in
/// changed: its endpoint, its own authentication, or the credential it
/// uses.
ConnectionSettingsChanged(SessionId),
/// A forwarding rule now listens or connects somewhere else, so a
/// running forward has to restart to follow it.
ForwardSettingsChanged(ForwardId),
}
impl EventEmitter<SessionStoreEvent> for SessionStore {}
impl SessionStore {
/// An empty memory-only store.
pub fn empty() -> Self {
Self {
groups: Vec::new(),
sessions: Vec::new(),
next_session_id: 1,
next_group_id: 1,
active: None,
recent: Vec::new(),
bookmarks: HashMap::new(),
forwards: Vec::new(),
next_forward_id: 1,
credentials: Vec::new(),
next_credential_id: 1,
database: None,
secrets: Arc::new(NoSecretStore),
key_dir: None,
}
}
/// Attach the system keychain. `main` does this once at startup; the UI
/// tests attach an in-memory store instead.
pub fn with_secrets(mut self, secrets: SharedSecretStore) -> Self {
self.secrets = secrets;
self
}
/// Keep pasted and generated private keys in `dir`. `main` puts it
/// beside the database; UI tests use a temporary directory.
pub fn with_key_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.key_dir = Some(dir.into());
self
}
/// Read everything back from `database` and keep writing to it. Ids carry
/// over from disk, so the allocators resume past the largest stored id.
pub fn load(database: SessionDatabase) -> rusqlite::Result<Self> {
let StoredData {
groups,
mut sessions,
mut recent,
bookmarks: stored_bookmarks,
forwards,
credentials,
} = database.load()?;
recent.truncate(MAX_RECENT);
let mut bookmarks: HashMap<_, Vec<String>> = HashMap::new();
for (session, side, path) in stored_bookmarks {
bookmarks.entry((session, side)).or_default().push(path);
}
// A write that failed after the fact can leave a host's copy of its
// credential's user name behind, or a link to a credential this
// build could not read. Memory is what counts, so it is put right
// here and reaches the file with the host's next save.
for session in &mut sessions {
let draft = normalized(&credentials, session.draft());
session.user = draft.user;
session.auth = draft.auth;
session.key_path = draft.key_path;
session.credential = draft.credential;
}
Ok(Self {
next_group_id: groups.iter().map(|group| group.id.0).max().unwrap_or(0) + 1,
next_session_id: sessions.iter().map(|s| s.id.0).max().unwrap_or(0) + 1,
next_forward_id: forwards.iter().map(|rule| rule.id.0).max().unwrap_or(0) + 1,
next_credential_id: credentials.iter().map(|c| c.id.0).max().unwrap_or(0) + 1,
forwards,
credentials,
groups,
sessions,
active: None,
recent,
bookmarks,
database: Some(database),
secrets: Arc::new(NoSecretStore),
key_dir: None,
})
}
/// Sample data for tests. Production starts from an empty database, so
/// nothing outside `tests/` builds a store this way; the UI tests depend
/// on the exact shape (ids 1..=6 in the order listed here).
pub fn seed() -> Self {
let mut store = Self::empty();
let production = store.insert_group_unnotified(GroupDraft::new("生产", None));
let staging = store.insert_group_unnotified(GroupDraft::new("测试", None));
let development = store.insert_group_unnotified(GroupDraft::new("开发", None));
let drafts = [
SessionDraft::new(
"web-01",
"10.0.1.12",
22,
"root",
AuthKind::Auto,
Some(production),
),
SessionDraft::new(
"web-02",
"10.0.1.13",
22,
"root",
AuthKind::Auto,
Some(production),
),
SessionDraft::new(
"db-01",
"10.0.2.5",
22,
"postgres",
AuthKind::Password,
Some(production),
),
SessionDraft::new(
"staging-api",
"10.0.9.20",
2222,
"deploy",
AuthKind::Auto,
Some(staging),
),
SessionDraft::new(
"qa-runner",
"10.0.9.31",
22,
"ci",
AuthKind::Password,
Some(staging),
),
SessionDraft::new(
"dev-box",
"192.168.1.20",
22,
"xuz",
AuthKind::Auto,
Some(development),
),
];
for draft in drafts {
store.insert_unnotified(draft);
}
for name in ["web-01", "staging-api"] {
if let Some(id) = store.sessions.iter().find(|s| s.name == name).map(|s| s.id) {
store.set_state_unnotified(id, ConnectionState::Connected);
}
}
store
}
pub fn groups(&self) -> &[SessionGroup] {
&self.groups
}
pub fn sessions(&self) -> &[Session] {
&self.sessions
}
pub fn session(&self, id: SessionId) -> Option<&Session> {
self.sessions.iter().find(|s| s.id == id)
}
pub fn group(&self, id: GroupId) -> Option<&SessionGroup> {
self.groups.iter().find(|g| g.id == id)
}
/// Every group below `id`, at any depth. Shared by the cascading delete
/// and by the check that keeps a group from being moved into itself.
pub fn descendant_groups(&self, id: GroupId) -> Vec<GroupId> {
let mut found = Vec::new();
let mut frontier = vec![id];
while let Some(current) = frontier.pop() {
for child in self.groups.iter().filter(|g| g.parent == Some(current)) {
found.push(child.id);
frontier.push(child.id);
}
}
found
}
/// Every session inside `id` or any group below it. What deleting the
/// group takes with it.
pub fn sessions_under(&self, id: GroupId) -> Vec<SessionId> {
let mut doomed = self.descendant_groups(id);
doomed.push(id);
self.sessions
.iter()
.filter(|session| session.group.is_some_and(|group| doomed.contains(&group)))
.map(|session| session.id)
.collect()
}
/// The groups above `id`, nearest parent first. Used to open the folders
/// over a row that has to be revealed.
pub fn ancestor_groups(&self, id: GroupId) -> Vec<GroupId> {
let mut chain = Vec::new();
let mut current = self.group(id).and_then(|group| group.parent);
while let Some(parent) = current {
if chain.len() > self.groups.len() {
break;
}
chain.push(parent);
current = self.group(parent).and_then(|group| group.parent);
}
chain
}
/// The group's full path, e.g. `生产 / 数据库`, as the forms list it.
pub fn group_path(&self, id: GroupId) -> String {
self.group_names(id).join(" / ")
}
/// The names along the path to a group, from the top level down to it.
pub fn group_names(&self, id: GroupId) -> Vec<&str> {
let mut parts: Vec<&str> = Vec::new();
let mut current = Some(id);
while let Some(group) = current.and_then(|id| self.group(id)) {
parts.push(group.name.as_ref());
// A cycle is rejected on the way in; refuse to hang if one ever
// reaches here anyway.
if parts.len() > self.groups.len() {
break;
}
current = group.parent;
}
parts.reverse();
parts
}
/// The session whose tab is currently displayed, if any.
pub fn active(&self) -> Option<&Session> {
self.active.and_then(|id| self.session(id))
}
/// Sessions in the order they last connected, most recent first. A
/// session joins (or moves to the front) each time it becomes connected.
pub fn recent_sessions(&self) -> impl Iterator<Item = &Session> {
self.recent.iter().filter_map(|id| self.session(*id))
}
pub fn insert(&mut self, draft: SessionDraft, cx: &mut Context<Self>) -> SessionId {
let id = self.insert_unnotified(draft);
if let Some(session) = self.session(id) {
self.persist("新建主机", cx, |db| db.insert_session(session));
}
cx.notify();
id
}
pub fn insert_unnotified(&mut self, draft: SessionDraft) -> SessionId {
let draft = normalized(&self.credentials, draft);
let sort_order = self.last_session_order(draft.group, None);
let id = SessionId(self.next_session_id);
self.next_session_id += 1;
let mut session = Session::new(id, draft);
session.public_id = self.unused_public_id();
session.sort_order = sort_order;
self.sessions.push(session);
id
}
/// A [`PublicId`] no session in the store has yet.
fn unused_public_id(&self) -> PublicId {
PublicId::generate_unused(|id| self.sessions.iter().any(|s| &s.public_id == id))
}
/// Replace the editable fields of a session; connection state is kept.
pub fn update(&mut self, id: SessionId, draft: SessionDraft, cx: &mut Context<Self>) -> bool {
let previous = self.login(id);
let updated = self.update_unnotified(id, draft);
if updated {
if let Some(session) = self.session(id) {
self.persist("保存主机", cx, |db| db.update_session(session));
}
if let Some(previous) = previous {
// The session no longer reads the password it did, say
// because it moved to another endpoint, so that keychain
// entry is an orphan unless another session still uses it.
self.forget_endpoint_password(previous.password.clone(), cx);
if self.login(id).as_ref() != Some(&previous) {
cx.emit(SessionStoreEvent::ConnectionSettingsChanged(id));
}
}
cx.notify();
}
updated
}
pub fn update_unnotified(&mut self, id: SessionId, draft: SessionDraft) -> bool {
let draft = normalized(&self.credentials, draft);
let new_order = self.last_session_order(draft.group, Some(id));
let Some(session) = self.sessions.iter_mut().find(|s| s.id == id) else {
return false;
};
// The form owns none of these, so rebuilding from the draft must not
// drop them.
let state = session.state;
let os = session.os;
let sort_order = session.sort_order;
let old_group = session.group;
let public_id = session.public_id.clone();
*session = Session::new(id, draft);
session.public_id = public_id;
session.state = state;
session.os = os;
session.sort_order = if session.group == old_group {
sort_order
} else {
new_order
};
true
}
pub fn remove(&mut self, id: SessionId, cx: &mut Context<Self>) -> bool {
let login = self.login(id);
let removed = self.remove_unnotified(id);
if removed {
self.persist("删除主机", cx, |db| db.remove_session(id));
if let Some(login) = login {
self.forget_endpoint_password(login.password, cx);
}
cx.notify();
}
removed
}
pub fn remove_unnotified(&mut self, id: SessionId) -> bool {
let before = self.sessions.len();
self.sessions.retain(|s| s.id != id);
self.recent.retain(|recent| *recent != id);
// The database drops both through `ON DELETE CASCADE`.
self.bookmarks.retain(|(session, _), _| *session != id);
self.forwards.retain(|rule| rule.session != id);
if self.active == Some(id) {
self.active = None;
}
self.sessions.len() != before
}
/// Copy a session as `<name> 副本`, placed right after the original.
pub fn duplicate(&mut self, id: SessionId, cx: &mut Context<Self>) -> Option<SessionId> {
let copy = self.duplicate_unnotified(id)?;
if let Some(session) = self.session(copy) {
self.persist("复制主机", cx, |db| db.insert_session(session));
self.persist("保存主机顺序", cx, |db| {
db.save_tree_order(&self.groups, &self.sessions)
});
}
cx.notify();
Some(copy)
}
pub fn duplicate_unnotified(&mut self, id: SessionId) -> Option<SessionId> {
let ix = self.sessions.iter().position(|s| s.id == id)?;
let mut draft = self.sessions[ix].draft();
draft.name = format!("{} 副本", draft.name).into();
// Same host, so the copy already knows what it will find there.
let os = self.sessions[ix].os;
let copy_id = SessionId(self.next_session_id);
self.next_session_id += 1;
let mut copy = Session::new(copy_id, draft);
// A copy is another session, so a third party must not confuse the two.
copy.public_id = self.unused_public_id();
copy.os = os;
copy.sort_order = self.sessions[ix].sort_order + 1;
for session in &mut self.sessions {
if session.group == copy.group && session.sort_order >= copy.sort_order {
session.sort_order += 1;
}
}
self.sessions.insert(ix + 1, copy);
Some(copy_id)
}
pub fn insert_group(&mut self, draft: GroupDraft, cx: &mut Context<Self>) -> GroupId {
let id = self.insert_group_unnotified(draft);
if let Some(group) = self.group(id) {
self.persist("新建分组", cx, |db| db.insert_group(group));
}
cx.notify();
id
}
pub fn insert_group_unnotified(&mut self, draft: GroupDraft) -> GroupId {
let sort_order = self.last_group_order(draft.parent, None);
let id = GroupId(self.next_group_id);
self.next_group_id += 1;
let mut group = SessionGroup::new(id, draft);
group.sort_order = sort_order;
self.groups.push(group);
id
}
/// Rename a group, and optionally move it under a different parent.
pub fn update_group(&mut self, id: GroupId, draft: GroupDraft, cx: &mut Context<Self>) -> bool {
let updated = self.update_group_unnotified(id, draft);
if updated {
if let Some(group) = self.group(id) {
self.persist("保存分组", cx, |db| db.update_group(group));
}
cx.notify();
}
updated
}
/// Returns whether the group changed. Moving a group into its own subtree
/// would cut that subtree off from the root, so it is refused here as well
/// as being left out of the form's parent list.
pub fn update_group_unnotified(&mut self, id: GroupId, draft: GroupDraft) -> bool {
if self.group(id).is_none() {
return false;
}
if let Some(parent) = draft.parent
&& (parent == id || self.descendant_groups(id).contains(&parent))
{
return false;
}
let new_order = self.last_group_order(draft.parent, Some(id));
let Some(group) = self.groups.iter_mut().find(|g| g.id == id) else {
return false;
};
let old_parent = group.parent;
let old_order = group.sort_order;
let expanded = group.expanded;
*group = SessionGroup::new(id, draft);
group.expanded = expanded;
group.sort_order = if group.parent == old_parent {
old_order
} else {
new_order
};
true
}
/// A session's SFTP bookmarks for one pane, oldest first.
pub fn bookmarks(&self, id: SessionId, side: BookmarkSide) -> &[String] {
self.bookmarks
.get(&(id, side))
.map(Vec::as_slice)
.unwrap_or_default()
}
pub fn add_bookmark(
&mut self,
id: SessionId,
side: BookmarkSide,
path: &str,
cx: &mut Context<Self>,
) -> bool {
if !self.add_bookmark_unnotified(id, side, path) {
return false;
}
self.persist("添加书签", cx, |db| db.insert_bookmark(id, side, path));
cx.notify();
true
}
pub fn add_bookmark_unnotified(
&mut self,
id: SessionId,
side: BookmarkSide,
path: &str,
) -> bool {
if self.session(id).is_none() || path.is_empty() {
return false;
}
let list = self.bookmarks.entry((id, side)).or_default();
if list.iter().any(|existing| existing == path) {
return false;
}
list.push(path.to_string());
true
}
pub fn remove_bookmark(
&mut self,
id: SessionId,
side: BookmarkSide,
path: &str,
cx: &mut Context<Self>,
) -> bool {
if !self.remove_bookmark_unnotified(id, side, path) {
return false;
}
self.persist("删除书签", cx, |db| db.remove_bookmark(id, side, path));
cx.notify();
true
}
pub fn remove_bookmark_unnotified(
&mut self,
id: SessionId,
side: BookmarkSide,
path: &str,
) -> bool {
let Some(list) = self.bookmarks.get_mut(&(id, side)) else {
return false;
};
let before = list.len();
list.retain(|existing| existing != path);
list.len() != before
}
/// Move a bookmark to position `to` of its pane's list (WinSCP's 上移 /
/// 下移).
pub fn move_bookmark(
&mut self,
id: SessionId,
side: BookmarkSide,
path: &str,
to: usize,
cx: &mut Context<Self>,
) -> bool {
if !self.move_bookmark_unnotified(id, side, path, to) {
return false;
}
self.persist("调整书签顺序", cx, |db| {
db.set_bookmark_order(id, side, self.bookmarks(id, side))
});
cx.notify();
true
}
pub fn move_bookmark_unnotified(
&mut self,
id: SessionId,
side: BookmarkSide,
path: &str,
to: usize,
) -> bool {
let Some(list) = self.bookmarks.get_mut(&(id, side)) else {
return false;
};
let Some(from) = list.iter().position(|existing| existing == path) else {
return false;
};
let to = to.min(list.len() - 1);
if from == to {
return false;
}
let path = list.remove(from);
list.insert(to, path);
true
}
/// Every port-forwarding rule, in list order.
pub fn forwards(&self) -> &[ForwardRule] {
&self.forwards
}
pub fn forward(&self, id: ForwardId) -> Option<&ForwardRule> {
self.forwards.iter().find(|rule| rule.id == id)
}
/// The rules that go through `session`: what deleting it takes along.
pub fn forwards_of(&self, session: SessionId) -> impl Iterator<Item = &ForwardRule> {
self.forwards
.iter()
.filter(move |rule| rule.session == session)
}
/// Add a rule at the end of the list. `None` when the draft names a
/// session that is not there.
pub fn insert_forward(
&mut self,
draft: ForwardDraft,
cx: &mut Context<Self>,
) -> Option<ForwardId> {
let id = self.insert_forward_unnotified(draft)?;
if let Some(rule) = self.forward(id) {
self.persist("新建端口转发", cx, |db| db.insert_forward(rule));
}
cx.notify();
Some(id)
}
pub fn insert_forward_unnotified(&mut self, draft: ForwardDraft) -> Option<ForwardId> {
self.session(draft.session)?;
let sort_order = self
.forwards
.iter()
.map(|rule| rule.sort_order)
.max()
.unwrap_or(-1)
+ 1;
let id = ForwardId(self.next_forward_id);
self.next_forward_id += 1;
let mut rule = ForwardRule::new(id, draft);
rule.sort_order = sort_order;
self.forwards.push(rule);
Some(id)
}
/// Replace the editable fields of a rule; its place in the list is kept.
pub fn update_forward(
&mut self,
id: ForwardId,
draft: ForwardDraft,
cx: &mut Context<Self>,
) -> bool {
let needs_restart = self
.forward(id)
.is_some_and(|rule| rule.needs_restart(&draft));
if !self.update_forward_unnotified(id, draft) {
return false;
}
if let Some(rule) = self.forward(id) {
self.persist("保存端口转发", cx, |db| db.update_forward(rule));
}
if needs_restart {
cx.emit(SessionStoreEvent::ForwardSettingsChanged(id));
}
cx.notify();
true
}
pub fn update_forward_unnotified(&mut self, id: ForwardId, draft: ForwardDraft) -> bool {
if self.session(draft.session).is_none() {
return false;
}
let Some(rule) = self.forwards.iter_mut().find(|rule| rule.id == id) else {
return false;
};
let sort_order = rule.sort_order;
*rule = ForwardRule::new(id, draft);
rule.sort_order = sort_order;
true
}
pub fn remove_forward(&mut self, id: ForwardId, cx: &mut Context<Self>) -> bool {
if !self.remove_forward_unnotified(id) {
return false;
}
self.persist("删除端口转发", cx, |db| db.remove_forward(id));
cx.notify();
true
}
pub fn remove_forward_unnotified(&mut self, id: ForwardId) -> bool {
let before = self.forwards.len();
self.forwards.retain(|rule| rule.id != id);
self.forwards.len() != before
}
/// Every credential, in list order.
pub fn credentials(&self) -> &[Credential] {
&self.credentials
}
pub fn credential(&self, id: CredentialId) -> Option<&Credential> {
self.credentials
.iter()
.find(|credential| credential.id == id)
}
/// The sessions that log in with `credential`.
pub fn sessions_using(&self, credential: CredentialId) -> impl Iterator<Item = &Session> {
self.sessions
.iter()
.filter(move |session| session.credential == Some(credential))
}
/// How a session logs in, with its credential looked up. What the
/// terminal, SFTP, forward and CLI workers are given.
pub fn login(&self, id: SessionId) -> Option<SessionLogin> {
self.session(id).map(|session| self.login_of(session))
}
pub fn login_of(&self, session: &Session) -> SessionLogin {
SessionLogin::of(
session,
session.credential.and_then(|id| self.credential(id)),
)
}
/// Add a credential at the end of the list.
pub fn insert_credential(
&mut self,
draft: CredentialDraft,
cx: &mut Context<Self>,
) -> CredentialId {
let id = self.insert_credential_unnotified(draft);
if let Some(credential) = self.credential(id) {
self.persist("新建凭据", cx, |db| db.insert_credential(credential));
}
cx.notify();
id
}
pub fn insert_credential_unnotified(&mut self, draft: CredentialDraft) -> CredentialId {
let sort_order = self
.credentials
.iter()
.map(|credential| credential.sort_order)
.max()
.unwrap_or(-1)
+ 1;
let id = CredentialId(self.next_credential_id);
self.next_credential_id += 1;
let mut credential = Credential::new(id, draft);
credential.keychain_id = PublicId::generate_unused(|candidate| {
self.credentials
.iter()
.any(|credential| &credential.keychain_id == candidate)
});
credential.sort_order = sort_order;
self.credentials.push(credential);
id
}
/// Replace the editable fields of a credential. Every session using it
/// takes the new user name, and those whose login changed reconnect.
pub fn update_credential(
&mut self,
id: CredentialId,
draft: CredentialDraft,
cx: &mut Context<Self>,
) -> bool {
let Some(previous) = self.credential(id).cloned() else {
return false;
};
let logins: Vec<(SessionId, SessionLogin)> = self
.sessions_using(id)
.map(|session| (session.id, self.login_of(session)))
.collect();
if !self.update_credential_unnotified(id, draft) {
return false;
}
if let Some(credential) = self.credential(id) {
self.persist("保存凭据", cx, |db| db.update_credential(credential));
}
if previous.kind == CredentialKind::Password
&& self
.credential(id)
.is_some_and(|credential| credential.kind != CredentialKind::Password)
{
self.save_secret(previous.password_secret(), None, cx);
}
if let Some(path) = previous.key_path.as_deref()
&& self
.credential(id)
.is_some_and(|credential| credential.key_path.as_deref() != Some(path))
{
self.forget_key_file(path, cx);
}
for (session, before) in logins {
self.forget_endpoint_password(before.password.clone(), cx);
if self.login(session).as_ref() != Some(&before) {
cx.emit(SessionStoreEvent::ConnectionSettingsChanged(session));
}
}
cx.notify();
true
}
pub fn update_credential_unnotified(
&mut self,
id: CredentialId,
draft: CredentialDraft,
) -> bool {
let Some(credential) = self
.credentials
.iter_mut()
.find(|credential| credential.id == id)
else {
return false;
};
let keychain_id = credential.keychain_id.clone();
let sort_order = credential.sort_order;
*credential = Credential::new(id, draft);
credential.keychain_id = keychain_id;
credential.sort_order = sort_order;
let user = credential.user.clone();
for session in &mut self.sessions {
if session.credential == Some(id) {
session.user = user.clone();
}
}
true
}
/// Delete a credential. The sessions using it log in on their own again,
/// automatically and as the same user, and are returned. They are not
/// reconnected: deleting a credential does not drop a working connection.
pub fn remove_credential(
&mut self,
id: CredentialId,
cx: &mut Context<Self>,
) -> Vec<SessionId> {
let Some(credential) = self.credential(id).cloned() else {
return Vec::new();
};
let released = self.remove_credential_unnotified(id);
self.persist("删除凭据", cx, |db| db.remove_credential(id));
if credential.kind == CredentialKind::Password {
self.save_secret(credential.password_secret(), None, cx);
}
if let Some(path) = credential.key_path.as_deref() {
self.forget_key_file(path, cx);
}
cx.notify();
released
}
pub fn remove_credential_unnotified(&mut self, id: CredentialId) -> Vec<SessionId> {
let before = self.credentials.len();
self.credentials.retain(|credential| credential.id != id);
if self.credentials.len() == before {
return Vec::new();
}
let mut released = Vec::new();
for session in &mut self.sessions {
if session.credential == Some(id) {
session.credential = None;
session.auth = AuthKind::Auto;
session.key_path = None;
released.push(session.id);
}
}
released
}
/// Persist a group's expanded or collapsed state after a tree interaction.
pub fn set_group_expanded(
&mut self,
id: GroupId,
expanded: bool,
cx: &mut Context<Self>,
) -> bool {
let Some(group) = self.groups.iter_mut().find(|group| group.id == id) else {
return false;
};
if group.expanded == expanded {
return false;
}
group.expanded = expanded;
self.persist("保存分组展开状态", cx, |db| {
db.set_group_expanded(id, expanded)
});
cx.notify();
true
}
/// Apply one expansion choice to the whole tree and persist it together.
pub fn set_all_groups_expanded(&mut self, expanded: bool, cx: &mut Context<Self>) -> bool {
if self.groups.iter().all(|group| group.expanded == expanded) {
return false;
}
for group in &mut self.groups {
group.expanded = expanded;
}
self.persist("保存所有分组展开状态", cx, |db| {
db.set_all_groups_expanded(expanded)
});
cx.notify();
true
}
/// Move a group or host in the tree and persist its new location and
/// sibling order in one transaction.
pub fn move_node(
&mut self,
source: SessionNode,
drop: NodeDrop,
cx: &mut Context<Self>,
) -> bool {
if !self.move_node_unnotified(source, drop) {
return false;
}
self.persist("调整主机顺序", cx, |db| {
db.save_tree_order(&self.groups, &self.sessions)
});
cx.notify();
true
}
pub fn move_node_unnotified(&mut self, source: SessionNode, drop: NodeDrop) -> bool {
let (parent, target, after) = match drop {
NodeDrop::Before(target) => (self.node_parent(target), Some(target), false),
NodeDrop::After(target) => (self.node_parent(target), Some(target), true),
NodeDrop::Into(group) if self.group(group).is_some() => {
(Some(Some(group)), None, false)
}
NodeDrop::Root => (Some(None), None, false),
_ => return false,
};
let Some(parent) = parent else {
return false;
};
if target == Some(source) {
return false;
}
match source {
SessionNode::Group(id) => {
if self.group(id).is_none() {
return false;
}
if parent == Some(id)
|| parent.is_some_and(|parent| self.descendant_groups(id).contains(&parent))
{
return false;
}
let target = match target {
Some(SessionNode::Group(target)) => Some(target),
Some(SessionNode::Session(_)) => return false,
None => None,
};
let siblings = self
.groups
.iter()
.filter(|g| g.parent == parent)
.map(|g| (g.sort_order, g.id))
.collect();
let Some(ids) = reordered(siblings, id, target, after) else {
return false;
};
for group in &mut self.groups {
if group.id == id {
group.parent = parent;
}
if let Some(order) = ids.iter().position(|sibling| *sibling == group.id) {
group.sort_order = order as i64;
}
}
}
SessionNode::Session(id) => {
if self.session(id).is_none() {
return false;
}
let target = match target {
Some(SessionNode::Session(target)) => Some(target),
Some(SessionNode::Group(_)) => return false,
None => None,
};
let siblings = self
.sessions
.iter()
.filter(|s| s.group == parent)
.map(|s| (s.sort_order, s.id))
.collect();
let Some(ids) = reordered(siblings, id, target, after) else {
return false;
};
for session in &mut self.sessions {
if session.id == id {
session.group = parent;
}
if let Some(order) = ids.iter().position(|sibling| *sibling == session.id) {
session.sort_order = order as i64;
}
}
}
}
true
}
fn node_parent(&self, node: SessionNode) -> Option<Option<GroupId>> {
match node {
SessionNode::Group(id) => self.group(id).map(|g| g.parent),
SessionNode::Session(id) => self.session(id).map(|s| s.group),
}
}
/// Delete a group with its subgroups and every session inside them.
/// Returns the sessions that went with it, so the workspace can close
/// their tabs.
pub fn remove_group(&mut self, id: GroupId, cx: &mut Context<Self>) -> Vec<SessionId> {
if self.group(id).is_none() {
return Vec::new();
}
let endpoints = self.endpoints_of(&self.sessions_under(id));
let removed = self.remove_group_unnotified(id);
// One delete mirrors the whole subtree: both foreign keys cascade.
self.persist("删除分组", cx, |db| db.remove_group(id));
for endpoint in endpoints {
self.forget_endpoint_password(endpoint, cx);
}
cx.notify();
removed
}
pub fn remove_group_unnotified(&mut self, id: GroupId) -> Vec<SessionId> {
if self.group(id).is_none() {
return Vec::new();
}
let removed = self.sessions_under(id);
let mut doomed = self.descendant_groups(id);
doomed.push(id);
self.groups.retain(|group| !doomed.contains(&group.id));
for session in &removed {
self.remove_unnotified(*session);
}
removed
}
pub fn set_state(&mut self, id: SessionId, state: ConnectionState, cx: &mut Context<Self>) {
if !self.set_state_unnotified(id, state) {
return;
}
if state.is_connected() {
self.persist("记录最近连接", cx, |db| {
db.touch_connected(id, now_seconds())
});
}
cx.notify();
}
/// Returns whether the state changed. Becoming connected also moves the
/// session to the front of the recent list.
pub fn set_state_unnotified(&mut self, id: SessionId, state: ConnectionState) -> bool {
let Some(session) = self.sessions.iter_mut().find(|s| s.id == id) else {
return false;
};
if session.state == state {
return false;
}
session.state = state;
if state.is_connected() {
self.recent.retain(|recent| *recent != id);
self.recent.insert(0, id);
self.recent.truncate(MAX_RECENT);
}
true
}
/// Record what a connection found on the host. Called after every
/// successful connection, so a rebuilt machine corrects itself.
pub fn set_host_os(&mut self, id: SessionId, os: Option<HostOs>, cx: &mut Context<Self>) {
if !self.set_host_os_unnotified(id, os) {
return;
}
self.persist("记录主机系统", cx, |db| db.set_host_os(id, os));
cx.notify();
}
/// Returns whether the recorded system changed.
pub fn set_host_os_unnotified(&mut self, id: SessionId, os: Option<HostOs>) -> bool {
let Some(session) = self.sessions.iter_mut().find(|s| s.id == id) else {
return false;
};
if session.os == os {
return false;
}
session.os = os;
true
}
pub fn set_active(&mut self, id: Option<SessionId>, cx: &mut Context<Self>) {
if self.active != id {
self.active = id;
cx.notify();
}
}
/// Where passwords are read from. The session form uses this to pre-fill
/// its field; the workspace hands it to the SSH provider.
pub fn secrets(&self) -> SharedSecretStore {
self.secrets.clone()
}
/// Where pasted and generated private keys are kept, if anywhere.
pub fn key_dir(&self) -> Option<&Path> {
self.key_dir.as_deref()
}
/// Save the text of a private key pasted into or generated by the form
/// for `credential` (`None` while it is being created) and say where it
/// went.
///
/// A key file of ShellRS's own that nothing else logs in with is written
/// over: the credential keeps its path, so the hosts using it are not
/// reconnected with a key no server has been given yet. Anything else
/// gets a new file. Writes on the calling thread, like the database:
/// it is one small file, and the credential must not refer to it before
/// it is there.
pub fn save_private_key(
&self,
credential: Option<CredentialId>,
text: &str,
) -> io::Result<SharedString> {
let dir = self
.key_dir
.as_deref()
.ok_or_else(|| io::Error::other("没有可以保存私钥的目录"))?;
let replace = credential
.and_then(|id| self.credential(id))
.and_then(|credential| credential.key_path.as_deref())
.filter(|path| self.keeps_key(path) && self.key_file_users(path) == 1)
.map(Path::new);
let path = write_key_file(dir, replace, text)?;
Ok(path.display().to_string().into())
}
/// Whether `path` is a key file ShellRS keeps itself.
fn keeps_key(&self, path: &str) -> bool {
self.key_dir
.as_deref()
.is_some_and(|dir| is_kept_in(dir, Path::new(path)))
}
/// How many credentials and hosts log in with the key file at `path`.
/// A host using a credential has no key file of its own.
fn key_file_users(&self, path: &str) -> usize {
let credentials = self
.credentials
.iter()
.filter(|credential| credential.key_path.as_deref() == Some(path))
.count();
let sessions = self
.sessions
.iter()
.filter(|session| session.key_path.as_deref() == Some(path))
.count();
credentials + sessions
}
/// Delete a key file ShellRS keeps, with its saved passphrase, once
/// nothing logs in with it any more. A key file anywhere else is the
/// user's and is never touched.
fn forget_key_file(&mut self, path: &str, cx: &mut Context<Self>) {
if !self.keeps_key(path) || self.key_file_users(path) > 0 {
return;
}
if let Err(error) = std::fs::remove_file(path)
&& error.kind() != io::ErrorKind::NotFound
{
cx.emit(SessionStoreEvent::PersistFailed(
format!("ShellRS 保存的私钥未能删除:{error}").into(),
));
}
self.save_secret(SecretRef::passphrase(path), None, cx);
}
/// Write a secret to the system keychain, or delete it when `value` is
/// `None`.
///
/// The keychain call blocks and on macOS may raise a system authorization
/// dialog, so it runs on a background thread. A failure costs persistence,
/// never the edit: it comes back as `PersistFailed`, exactly like a failed
/// database write.
pub fn save_secret(
&mut self,
secret: SecretRef,
value: Option<String>,
cx: &mut Context<Self>,
) {
let secrets = self.secrets.clone();
let failure = match (&secret, value.is_some()) {
(SecretRef::Password { .. }, true) => "密码未能写入系统钥匙串",
(SecretRef::Password { .. }, false) => "密码未能从系统钥匙串删除",
(SecretRef::Passphrase { .. }, true) => "私钥口令未能写入系统钥匙串",
(SecretRef::Passphrase { .. }, false) => "私钥口令未能从系统钥匙串删除",
(SecretRef::Credential { .. }, true) => "凭据的密码未能写入系统钥匙串",
(SecretRef::Credential { .. }, false) => "凭据的密码未能从系统钥匙串删除",
};
cx.spawn(async move |this, cx| {
let result = cx
.background_executor()
.spawn(async move {
match value {
Some(value) => secrets.set(&secret, &value),
None => secrets.delete(&secret),
}
})
.await;
if let Err(error) = result {
this.update(cx, |_, cx| {
cx.emit(SessionStoreEvent::PersistFailed(
format!("{failure}:{error}").into(),
));
})
.ok();
}
})
.detach();
}
/// The distinct password entries these sessions log in with.
fn endpoints_of(&self, ids: &[SessionId]) -> Vec<SecretRef> {
let mut endpoints: Vec<SecretRef> = Vec::new();
for secret in ids
.iter()
.filter_map(|id| self.login(*id))
.map(|login| login.password)
{
if !endpoints.contains(&secret) {
endpoints.push(secret);
}
}
endpoints
}
/// Delete an endpoint's saved password once no session logs in with it.
/// A credential's password is left alone: it goes with the credential,
/// not with the sessions using it.
fn forget_endpoint_password(&mut self, secret: SecretRef, cx: &mut Context<Self>) {
if matches!(secret, SecretRef::Password { .. }) && !self.password_in_use(&secret) {
self.save_secret(secret, None, cx);
}
}
/// Whether any session still logs in with this password entry. Entries
/// are shared, so one may only be cleaned up once the last session using
/// it is gone.
fn password_in_use(&self, secret: &SecretRef) -> bool {
self.sessions
.iter()
.any(|session| self.login_of(session).password == *secret)
}
/// Write a change through to the database, when there is one. A failed
/// write becomes an event; the in-memory change stands.
fn persist(
&self,
action: &str,
cx: &mut Context<Self>,
write: impl FnOnce(&SessionDatabase) -> rusqlite::Result<()>,
) {
if let Some(database) = &self.database
&& let Err(error) = write(database)
{
cx.emit(SessionStoreEvent::PersistFailed(
format!("{action}未能保存到本地数据库:{error}").into(),
));
}
}
/// The sort order that puts a session last in `group`, among the
/// sessions other than `except`.
fn last_session_order(&self, group: Option<GroupId>, except: Option<SessionId>) -> i64 {
self.sessions
.iter()
.filter(|s| Some(s.id) != except && s.group == group)
.map(|s| s.sort_order)
.max()
.unwrap_or(-1)
+ 1
}
/// The sort order that puts a group last under `parent`, among the
/// groups other than `except`.
fn last_group_order(&self, parent: Option<GroupId>, except: Option<GroupId>) -> i64 {
self.groups
.iter()
.filter(|g| Some(g.id) != except && g.parent == parent)
.map(|g| g.sort_order)
.max()
.unwrap_or(-1)
+ 1
}
}
/// The order `id` and its new siblings end up in: `id` goes before or after
/// `target`, or to the end without one. `siblings` are the rows under the new
/// parent as `(sort_order, id)`, with `id` among them when it is not changing
/// parent. `None` when `target` is not one of them, or nothing would move.
fn reordered<Id: Copy + Ord>(
mut siblings: Vec<(i64, Id)>,
id: Id,
target: Option<Id>,
after: bool,
) -> Option<Vec<Id>> {
siblings.sort();
let old_index = siblings.iter().position(|(_, sibling)| *sibling == id);
let mut ids: Vec<Id> = siblings
.into_iter()
.map(|(_, sibling)| sibling)
.filter(|sibling| *sibling != id)
.collect();
let index = match target {
Some(target) => ids.iter().position(|sibling| *sibling == target)? + usize::from(after),
None => ids.len(),
};
if old_index == Some(index) {
return None;
}
ids.insert(index, id);
Some(ids)
}
/// `draft` as the store keeps it. A session using a credential logs in as
/// the credential's user and keeps nothing of its own login; one naming a
/// credential that is not there logs in on its own.
fn normalized(credentials: &[Credential], mut draft: SessionDraft) -> SessionDraft {
let credential = draft
.credential
.and_then(|id| credentials.iter().find(|credential| credential.id == id));
match credential {
Some(credential) => {
draft.user = credential.user.clone();
draft.auth = AuthKind::Auto;
draft.key_path = None;
}
None => draft.credential = None,
}
draft
}
#[cfg(test)]
mod tests {
use super::super::{ForwardEndpoint, ForwardKind};
use super::*;
fn draft(name: &str, group: Option<GroupId>) -> SessionDraft {
SessionDraft::new(name, "10.0.0.1", 22, "root", AuthKind::Auto, group)
}
#[test]
fn bookmarks_are_per_pane_and_leave_with_their_session() {
let mut store = SessionStore::empty();
let group = store.insert_group_unnotified(GroupDraft::new("生产", None));
let web = store.insert_unnotified(draft("web", Some(group)));
let db = store.insert_unnotified(draft("db", None));
assert!(store.add_bookmark_unnotified(web, BookmarkSide::Remote, "/var/log"));
assert!(!store.add_bookmark_unnotified(web, BookmarkSide::Remote, "/var/log"));
assert!(store.add_bookmark_unnotified(web, BookmarkSide::Local, "/tmp"));
assert!(store.add_bookmark_unnotified(db, BookmarkSide::Remote, "/srv"));
assert!(!store.add_bookmark_unnotified(SessionId(99), BookmarkSide::Remote, "/x"));
assert_eq!(store.bookmarks(web, BookmarkSide::Remote), ["/var/log"]);
assert_eq!(store.bookmarks(web, BookmarkSide::Local), ["/tmp"]);
assert!(store.remove_bookmark_unnotified(db, BookmarkSide::Remote, "/srv"));
assert!(store.bookmarks(db, BookmarkSide::Remote).is_empty());
assert!(store.add_bookmark_unnotified(web, BookmarkSide::Remote, "/etc"));
assert!(store.add_bookmark_unnotified(web, BookmarkSide::Remote, "/opt"));
assert!(store.move_bookmark_unnotified(web, BookmarkSide::Remote, "/opt", 0));
assert!(!store.move_bookmark_unnotified(web, BookmarkSide::Remote, "/opt", 0));
assert!(store.move_bookmark_unnotified(web, BookmarkSide::Remote, "/var/log", 9));
assert_eq!(
store.bookmarks(web, BookmarkSide::Remote),
["/opt", "/etc", "/var/log"]
);
assert!(!store.move_bookmark_unnotified(web, BookmarkSide::Remote, "/missing", 0));
// Removing a group matches the database cascade.
store.remove_group_unnotified(group);
assert!(store.bookmarks(web, BookmarkSide::Remote).is_empty());
assert!(store.bookmarks(web, BookmarkSide::Local).is_empty());
}
fn forward_draft(session: SessionId, port: u16) -> ForwardDraft {
ForwardDraft::new(
ForwardKind::Local,
session,
ForwardEndpoint::new("127.0.0.1", port),
Some(ForwardEndpoint::new("db.internal", 3306)),
)
}
#[test]
fn forwards_keep_their_order_and_leave_with_their_session() {
let mut store = SessionStore::empty();
let group = store.insert_group_unnotified(GroupDraft::new("生产", None));
let web = store.insert_unnotified(draft("web", Some(group)));
let db = store.insert_unnotified(draft("db", None));
assert_eq!(
store.insert_forward_unnotified(forward_draft(SessionId(99), 1)),
None,
"a rule needs a session to go through"
);
let first = store
.insert_forward_unnotified(forward_draft(web, 8080))
.unwrap();
let second = store
.insert_forward_unnotified(forward_draft(db, 8081))
.unwrap();
let third = store
.insert_forward_unnotified(forward_draft(web, 8082))
.unwrap();
assert_eq!([first, second, third].map(|id| id.0), [1, 2, 3]);
assert_eq!(
store
.forwards()
.iter()
.map(|rule| rule.sort_order)
.collect::<Vec<_>>(),
[0, 1, 2]
);
assert_eq!(store.forwards_of(web).count(), 2);
// Editing keeps the rule's place; it cannot move to a missing session.
assert!(store.update_forward_unnotified(second, forward_draft(web, 9000)));
assert_eq!(store.forward(second).unwrap().bind.port, 9000);
assert_eq!(store.forward(second).unwrap().sort_order, 1);
assert!(!store.update_forward_unnotified(second, forward_draft(SessionId(99), 1)));
assert!(!store.update_forward_unnotified(ForwardId(99), forward_draft(web, 1)));
assert!(store.remove_forward_unnotified(first));
assert!(!store.remove_forward_unnotified(first));
// A copy of a session starts without forwards, like bookmarks.
let copy = store.duplicate_unnotified(web).unwrap();
assert_eq!(store.forwards_of(copy).count(), 0);
// Removing a group matches the database cascade.
store.remove_group_unnotified(group);
assert!(store.forwards().is_empty());
// Ids are not handed out again within a run.
assert_eq!(
store.insert_forward_unnotified(forward_draft(db, 1)),
Some(ForwardId(4))
);
}
#[test]
fn load_resumes_the_forward_id_sequence() {
let database = SessionDatabase::in_memory().unwrap();
database
.insert_session(&Session::new(SessionId(1), draft("web", None)))
.unwrap();
database
.insert_forward(&ForwardRule::new(
ForwardId(5),
forward_draft(SessionId(1), 8080),
))
.unwrap();
let mut store = SessionStore::load(database).unwrap();
assert_eq!(store.forwards().len(), 1);
assert_eq!(
store.insert_forward_unnotified(forward_draft(SessionId(1), 8081)),
Some(ForwardId(6))
);
}
fn password_credential(store: &mut SessionStore, user: &str) -> CredentialId {
store.insert_credential_unnotified(CredentialDraft::new(
"运维",
CredentialKind::Password,
user,
))
}
#[test]
fn a_host_using_a_credential_logs_in_as_its_user() {
let mut store = SessionStore::empty();
let credential = password_credential(&mut store, "deploy");
let mut form = draft("web", None).with_credential(credential);
// Whatever the form still held of its own login is dropped.
form.auth = AuthKind::Key;
form.key_path = Some("/tmp/id".into());
let web = store.insert_unnotified(form);
let session = store.session(web).unwrap();
assert_eq!(session.credential, Some(credential));
assert_eq!(session.user.as_ref(), "deploy");
assert_eq!(session.auth, AuthKind::Auto);
assert_eq!(session.key_path, None);
let login = store.login(web).unwrap();
assert_eq!(login.user, "deploy");
assert_eq!(
login.password,
store.credential(credential).unwrap().password_secret()
);
assert_eq!(store.sessions_using(credential).count(), 1);
}
#[test]
fn a_draft_naming_a_missing_credential_is_saved_as_manual() {
let mut store = SessionStore::empty();
let web = store.insert_unnotified(draft("web", None).with_credential(CredentialId(9)));
let session = store.session(web).unwrap();
assert_eq!(session.credential, None);
assert_eq!(session.user.as_ref(), "root");
}
#[test]
fn renaming_a_credentials_user_renames_every_host_using_it() {
let mut store = SessionStore::empty();
let credential = password_credential(&mut store, "deploy");
let web = store.insert_unnotified(draft("web", None).with_credential(credential));
let db = store.insert_unnotified(draft("db", None));
let mut edited = store.credential(credential).unwrap().draft();
edited.user = "admin".into();
assert!(store.update_credential_unnotified(credential, edited));
assert_eq!(store.session(web).unwrap().user.as_ref(), "admin");
assert_eq!(store.session(web).unwrap().address(), "admin@10.0.0.1:22");
assert_eq!(store.session(db).unwrap().user.as_ref(), "root");
assert!(!store.update_credential_unnotified(
CredentialId(9),
CredentialDraft::new("x", CredentialKind::Agent, "x")
));
}
#[test]
fn changing_only_a_credentials_name_changes_no_login() {
let mut store = SessionStore::empty();
let credential = password_credential(&mut store, "deploy");
let web = store.insert_unnotified(draft("web", None).with_credential(credential));
let before = store.login(web);
let keychain_id = store.credential(credential).unwrap().keychain_id.clone();
let mut edited = store.credential(credential).unwrap().draft();
edited.name = "生产运维".into();
assert!(store.update_credential_unnotified(credential, edited));
assert_eq!(store.login(web), before);
// The keychain entry stays the credential's.
assert_eq!(
store.credential(credential).unwrap().keychain_id,
keychain_id
);
}
#[test]
fn deleting_a_credential_leaves_its_hosts_manual_auto_with_the_user() {
let mut store = SessionStore::empty();
let credential = store.insert_credential_unnotified(
CredentialDraft::new("部署", CredentialKind::Key, "deploy").with_key_path("/tmp/id"),
);
let web = store.insert_unnotified(draft("web", None).with_credential(credential));
let db = store.insert_unnotified(draft("db", None));
assert_eq!(store.remove_credential_unnotified(credential), [web]);
assert!(store.credentials().is_empty());
let session = store.session(web).unwrap();
assert_eq!(session.credential, None);
assert_eq!(session.auth, AuthKind::Auto);
assert_eq!(session.key_path, None);
assert_eq!(session.user.as_ref(), "deploy");
assert_eq!(store.session(db).unwrap().user.as_ref(), "root");
assert!(store.remove_credential_unnotified(credential).is_empty());
}
#[test]
fn a_copy_of_a_host_uses_the_same_credential() {
let mut store = SessionStore::empty();
let credential = password_credential(&mut store, "deploy");
let web = store.insert_unnotified(draft("web", None).with_credential(credential));
let copy = store.duplicate_unnotified(web).unwrap();
assert_eq!(store.session(copy).unwrap().credential, Some(credential));
assert_eq!(store.sessions_using(credential).count(), 2);
}
#[test]
fn a_host_with_a_password_credential_does_not_keep_the_endpoint_password_in_use() {
let mut store = SessionStore::empty();
let credential = password_credential(&mut store, "root");
let web = store.insert_unnotified(draft("web", None));
let endpoint = store.session(web).unwrap().password_secret();
assert!(store.password_in_use(&endpoint));
store.update_unnotified(web, draft("web", None).with_credential(credential));
// Same user, host and port, but the password comes from elsewhere.
assert_eq!(store.session(web).unwrap().password_secret(), endpoint);
assert!(!store.password_in_use(&endpoint));
let secret = store.credential(credential).unwrap().password_secret();
assert!(store.password_in_use(&secret));
}
#[test]
fn load_resumes_the_credential_id_sequence_and_repairs_usernames() {
let database = SessionDatabase::in_memory().unwrap();
let credential = Credential::new(
CredentialId(4),
CredentialDraft::new("运维", CredentialKind::Agent, "deploy"),
);
database.insert_credential(&credential).unwrap();
let mut stale = Session::new(SessionId(1), draft("web", None));
stale.credential = Some(credential.id);
stale.auth = AuthKind::Password;
database.insert_session(&stale).unwrap();
let mut store = SessionStore::load(database).unwrap();
let session = store.session(SessionId(1)).unwrap();
assert_eq!(session.user.as_ref(), "deploy");
assert_eq!(session.auth, AuthKind::Auto);
assert_eq!(
store.credential(CredentialId(4)).unwrap().keychain_id,
credential.keychain_id
);
assert_eq!(
store.insert_credential_unnotified(CredentialDraft::new(
"x",
CredentialKind::Agent,
"x"
)),
CredentialId(5)
);
}
fn key_credential(store: &mut SessionStore, path: &str) -> CredentialId {
store.insert_credential_unnotified(
CredentialDraft::new("部署", CredentialKind::Key, "deploy").with_key_path(path),
)
}
fn read(path: &str) -> String {
std::fs::read_to_string(path).unwrap()
}
#[test]
fn a_pasted_key_gets_a_file_of_its_own_and_keeps_it_when_replaced() {
let data = tempfile::tempdir().unwrap();
let keys = data.path().join("keys");
let mut store = SessionStore::empty().with_key_dir(&keys);
// A credential being created: a new file in the key directory.
let path = store.save_private_key(None, "one").unwrap();
assert!(Path::new(path.as_ref()).starts_with(&keys));
let credential = key_credential(&mut store, &path);
assert!(
store
.credential(credential)
.unwrap()
.keeps_key_in(store.key_dir())
);
// Another key for it goes over the same file: the hosts using it
// keep their login, and nothing is left behind.
assert_eq!(
store.save_private_key(Some(credential), "two").unwrap(),
path
);
assert_eq!(read(&path), "two");
// Not when a host logs in with that file as well.
let mut host = draft("web", None);
host.auth = AuthKind::Key;
host.key_path = Some(path.clone());
store.insert_unnotified(host);
let moved = store.save_private_key(Some(credential), "three").unwrap();
assert_ne!(moved, path);
assert_eq!(read(&path), "two");
assert_eq!(read(&moved), "three");
// A key file of the user's is never written over.
let mine = data.path().join("id_mine");
std::fs::write(&mine, "mine").unwrap();
let theirs = key_credential(&mut store, &mine.display().to_string());
let saved = store.save_private_key(Some(theirs), "new").unwrap();
assert!(Path::new(saved.as_ref()).starts_with(&keys));
assert_eq!(std::fs::read_to_string(&mine).unwrap(), "mine");
}
#[test]
fn without_a_key_directory_no_key_is_saved() {
assert!(SessionStore::empty().save_private_key(None, "one").is_err());
}
#[gpui_kit::test]
fn a_kept_key_goes_with_the_last_credential_using_it(cx: &mut gpui_kit::TestAppContext) {
use crate::secrets::SecretStore as _;
use gpui_kit::AppContext as _;
let data = tempfile::tempdir().unwrap();
let secrets = Arc::new(crate::secrets::InMemorySecretStore::default());
let mut store = SessionStore::empty()
.with_secrets(secrets.clone())
.with_key_dir(data.path().join("keys"));
let kept = store.save_private_key(None, "kept").unwrap();
let one = key_credential(&mut store, &kept);
let two = key_credential(&mut store, &kept);
let passphrase = SecretRef::passphrase(kept.as_ref());
secrets.set(&passphrase, "correct horse").unwrap();
let mine = data.path().join("id_mine");
std::fs::write(&mine, "mine").unwrap();
let theirs = key_credential(&mut store, &mine.display().to_string());
let store = cx.new(|_| store);
store.update(cx, |store, cx| store.remove_credential(one, cx));
cx.run_until_parked();
assert!(
Path::new(kept.as_ref()).exists(),
"another credential still logs in with it"
);
store.update(cx, |store, cx| {
store.update_credential(
two,
CredentialDraft::new("部署", CredentialKind::Agent, "deploy"),
cx,
)
});
cx.run_until_parked();
assert!(!Path::new(kept.as_ref()).exists());
assert!(secrets.get(&passphrase).unwrap().is_none());
// The user's own key file stays, whatever becomes of the credential.
store.update(cx, |store, cx| store.remove_credential(theirs, cx));
cx.run_until_parked();
assert_eq!(std::fs::read_to_string(&mine).unwrap(), "mine");
}
#[test]
fn the_detected_system_survives_an_edit() {
let mut store = SessionStore::empty();
let id = store.insert_unnotified(draft("web", None));
assert!(store.set_host_os_unnotified(id, Some(HostOs::Debian)));
assert!(!store.set_host_os_unnotified(id, Some(HostOs::Debian)));
store.update_unnotified(id, draft("web-renamed", None));
assert_eq!(store.session(id).unwrap().os, Some(HostOs::Debian));
}
#[test]
fn a_copy_inherits_the_detected_system() {
let mut store = SessionStore::empty();
let id = store.insert_unnotified(draft("web", None));
store.set_host_os_unnotified(id, Some(HostOs::Alpine));
let copy = store.duplicate_unnotified(id).unwrap();
assert_eq!(store.session(copy).unwrap().os, Some(HostOs::Alpine));
}
#[test]
fn probing_an_unknown_session_changes_nothing() {
let mut store = SessionStore::empty();
assert!(!store.set_host_os_unnotified(SessionId(99), Some(HostOs::Linux)));
}
#[test]
fn an_endpoint_is_in_use_while_a_session_still_logs_into_it() {
let mut store = SessionStore::empty();
let id = store.insert_unnotified(draft("web", None));
let endpoint = store.session(id).unwrap().password_secret();
assert!(store.password_in_use(&endpoint));
store.remove_unnotified(id);
assert!(!store.password_in_use(&endpoint));
}
#[test]
fn two_sessions_on_one_account_share_an_endpoint() {
let mut store = SessionStore::empty();
let first = store.insert_unnotified(draft("web-01", None));
let second = store.insert_unnotified(draft("web-02", None));
let endpoint = store.session(first).unwrap().password_secret();
assert_eq!(endpoint, store.session(second).unwrap().password_secret());
store.remove_unnotified(first);
assert!(store.password_in_use(&endpoint));
}
#[test]
fn a_different_port_is_a_different_endpoint() {
let mut store = SessionStore::empty();
let id = store.insert_unnotified(draft("web", None));
let endpoint = store.session(id).unwrap().password_secret();
let moved = SessionDraft::new("web", "10.0.0.1", 2222, "root", AuthKind::Auto, None);
store.update_unnotified(id, moved);
assert!(!store.password_in_use(&endpoint));
}
#[test]
fn endpoints_of_a_group_are_deduplicated() {
let mut store = SessionStore::empty();
let group = store.insert_group_unnotified(GroupDraft::new("生产", None));
store.insert_unnotified(draft("web-01", Some(group)));
store.insert_unnotified(draft("web-02", Some(group)));
let other = SessionDraft::new("db", "10.0.0.2", 22, "root", AuthKind::Auto, Some(group));
store.insert_unnotified(other);
let endpoints = store.endpoints_of(&store.sessions_under(group));
assert_eq!(endpoints.len(), 2);
}
#[test]
fn seed_has_three_groups_and_two_connected_sessions() {
let store = SessionStore::seed();
assert_eq!(store.groups().len(), 3);
assert_eq!(store.sessions().len(), 6);
let connected: Vec<_> = store
.sessions()
.iter()
.filter(|s| s.state.is_connected())
.map(|s| s.name.as_ref())
.collect();
assert_eq!(connected, ["web-01", "staging-api"]);
}
#[test]
fn groups_and_sessions_have_independent_id_sequences() {
let mut store = SessionStore::seed();
let group_ids: Vec<_> = store.groups().iter().map(|g| g.id).collect();
assert_eq!(group_ids, [GroupId(1), GroupId(2), GroupId(3)]);
assert_eq!(store.sessions()[0].id, SessionId(1));
assert_eq!(store.sessions()[5].id, SessionId(6));
assert_eq!(
store.insert_group_unnotified(GroupDraft::new("预发", None)),
GroupId(4)
);
assert_eq!(store.insert_unnotified(draft("new", None)), SessionId(7));
}
#[test]
fn insert_assigns_increasing_ids_and_keeps_order() {
let mut store = SessionStore::empty();
let a = store.insert_unnotified(draft("a", Some(GroupId(1))));
let b = store.insert_unnotified(draft("b", Some(GroupId(1))));
assert!(a < b);
assert_eq!(store.sessions()[1].id, b);
}
#[test]
fn duplicate_places_a_copy_after_the_original() {
let mut store = SessionStore::seed();
let web01 = store.sessions()[0].id;
let mut draft = store.session(web01).unwrap().draft();
draft.auth = AuthKind::Key;
draft.key_path = Some("/tmp/id_ed25519".into());
assert!(store.update_unnotified(web01, draft));
let copy = store.duplicate_unnotified(web01).unwrap();
assert_eq!(store.sessions()[1].id, copy);
assert_eq!(store.sessions()[1].name.as_ref(), "web-01 副本");
assert_eq!(
store.sessions()[1].key_path.as_deref(),
Some("/tmp/id_ed25519")
);
assert_eq!(store.sessions()[1].state, ConnectionState::Disconnected);
assert_ne!(
store.sessions()[1].public_id,
store.session(web01).unwrap().public_id
);
}
#[test]
fn a_public_id_survives_editing_and_is_never_shared() {
let mut store = SessionStore::seed();
let web01 = store.sessions()[0].id;
let before = store.session(web01).unwrap().public_id.clone();
let mut draft = store.session(web01).unwrap().draft();
draft.name = "web".into();
draft.host = "10.0.1.99".into();
draft.group = None;
assert!(store.update_unnotified(web01, draft));
assert_eq!(store.session(web01).unwrap().public_id, before);
let ids: std::collections::HashSet<_> =
store.sessions().iter().map(|s| &s.public_id).collect();
assert_eq!(ids.len(), store.sessions().len());
}
#[test]
fn update_keeps_connection_state() {
let mut store = SessionStore::seed();
let web01 = store.sessions()[0].id;
let mut draft = store.session(web01).unwrap().draft();
draft.port = 2200;
assert!(store.update_unnotified(web01, draft));
let session = store.session(web01).unwrap();
assert_eq!(session.port, 2200);
assert_eq!(session.state, ConnectionState::Connected);
assert!(!store.update_unnotified(SessionId(999), session.draft()));
}
#[test]
fn remove_clears_active_session() {
let mut store = SessionStore::seed();
let web01 = store.sessions()[0].id;
store.active = Some(web01);
assert!(store.remove_unnotified(web01));
assert!(store.active().is_none());
assert!(!store.remove_unnotified(web01));
}
#[test]
fn seed_lists_connected_sessions_as_recent_newest_first() {
let store = SessionStore::seed();
let recent: Vec<_> = store.recent_sessions().map(|s| s.name.as_ref()).collect();
assert_eq!(recent, ["staging-api", "web-01"]);
}
#[test]
fn connecting_moves_a_session_to_the_front_of_recent() {
let mut store = SessionStore::seed();
let web01 = store.sessions()[0].id;
let db01 = store.sessions()[2].id;
assert!(store.set_state_unnotified(db01, ConnectionState::Connected));
// Already connected: nothing changes, nothing moves.
assert!(!store.set_state_unnotified(db01, ConnectionState::Connected));
assert!(store.set_state_unnotified(web01, ConnectionState::Disconnected));
assert!(store.set_state_unnotified(web01, ConnectionState::Connected));
let recent: Vec<_> = store.recent_sessions().map(|s| s.name.as_ref()).collect();
assert_eq!(recent, ["web-01", "db-01", "staging-api"]);
}
#[test]
fn remove_drops_the_session_from_recent() {
let mut store = SessionStore::seed();
let web01 = store.sessions()[0].id;
assert!(store.remove_unnotified(web01));
let recent: Vec<_> = store.recent_sessions().map(|s| s.name.as_ref()).collect();
assert_eq!(recent, ["staging-api"]);
}
#[test]
fn recent_keeps_only_the_newest_entries() {
let mut store = SessionStore::empty();
for ix in 0..(MAX_RECENT + 2) {
let id = store.insert_unnotified(draft(&format!("s{ix}"), None));
store.set_state_unnotified(id, ConnectionState::Connected);
}
assert_eq!(store.recent_sessions().count(), MAX_RECENT);
let newest = store.recent_sessions().next().unwrap();
assert_eq!(newest.name.as_ref(), "s11");
}
#[test]
fn nested_groups_report_descendants_and_path() {
let mut store = SessionStore::empty();
let production = store.insert_group_unnotified(GroupDraft::new("生产", None));
let databases = store.insert_group_unnotified(GroupDraft::new("数据库", Some(production)));
let replicas = store.insert_group_unnotified(GroupDraft::new("只读副本", Some(databases)));
let staging = store.insert_group_unnotified(GroupDraft::new("测试", None));
let mut descendants = store.descendant_groups(production);
descendants.sort();
assert_eq!(descendants, [databases, replicas]);
assert!(store.descendant_groups(replicas).is_empty());
assert_eq!(store.group_path(replicas), "生产 / 数据库 / 只读副本");
assert_eq!(store.group_path(staging), "测试");
}
#[test]
fn removing_a_group_takes_its_subtree_and_its_sessions() {
let mut store = SessionStore::empty();
let production = store.insert_group_unnotified(GroupDraft::new("生产", None));
let databases = store.insert_group_unnotified(GroupDraft::new("数据库", Some(production)));
let staging = store.insert_group_unnotified(GroupDraft::new("测试", None));
let web = store.insert_unnotified(draft("web-01", Some(production)));
let db = store.insert_unnotified(draft("db-01", Some(databases)));
let qa = store.insert_unnotified(draft("qa", Some(staging)));
let jump = store.insert_unnotified(draft("jump", None));
store.set_state_unnotified(db, ConnectionState::Connected);
store.active = Some(db);
let mut removed = store.remove_group_unnotified(production);
removed.sort();
assert_eq!(removed, [web, db]);
assert_eq!(
store.groups().iter().map(|g| g.id).collect::<Vec<_>>(),
[staging]
);
// The root-level session and the untouched group's session survive.
assert_eq!(
store.sessions().iter().map(|s| s.id).collect::<Vec<_>>(),
[qa, jump]
);
assert_eq!(store.recent_sessions().count(), 0);
assert!(store.active().is_none());
// Removing it a second time finds nothing left to do.
assert!(store.remove_group_unnotified(production).is_empty());
}
#[test]
fn a_group_cannot_be_moved_into_its_own_subtree() {
let mut store = SessionStore::empty();
let production = store.insert_group_unnotified(GroupDraft::new("生产", None));
let databases = store.insert_group_unnotified(GroupDraft::new("数据库", Some(production)));
let replicas = store.insert_group_unnotified(GroupDraft::new("只读副本", Some(databases)));
assert!(
!store.update_group_unnotified(production, GroupDraft::new("生产", Some(replicas)))
);
assert!(
!store.update_group_unnotified(production, GroupDraft::new("生产", Some(production)))
);
assert!(store.group(production).unwrap().parent.is_none());
// Moving in the other direction, and plain renaming, are fine.
assert!(store.update_group_unnotified(replicas, GroupDraft::new("副本", Some(production))));
assert_eq!(store.group_path(replicas), "生产 / 副本");
assert!(!store.update_group_unnotified(GroupId(99), GroupDraft::new("x", None)));
}
#[test]
fn renaming_a_group_preserves_its_expansion_choice() {
let mut store = SessionStore::empty();
let id = store.insert_group_unnotified(GroupDraft::new("生产", None));
store
.groups
.iter_mut()
.find(|group| group.id == id)
.unwrap()
.expanded = false;
assert!(store.update_group_unnotified(id, GroupDraft::new("生产环境", None)));
assert!(!store.group(id).unwrap().expanded);
}
#[test]
fn dragging_reorders_peers_and_moves_hosts_between_groups() {
let mut store = SessionStore::seed();
let web = SessionNode::Session(SessionId(1));
let db = SessionNode::Session(SessionId(3));
assert!(store.move_node_unnotified(db, NodeDrop::Before(web)));
let mut production: Vec<_> = store
.sessions()
.iter()
.filter(|session| session.group == Some(GroupId(1)))
.collect();
production.sort_by_key(|session| session.sort_order);
assert_eq!(
production
.iter()
.map(|session| session.id)
.collect::<Vec<_>>(),
[SessionId(3), SessionId(1), SessionId(2)]
);
assert!(!store.move_node_unnotified(db, NodeDrop::Before(web)));
assert!(store.move_node_unnotified(web, NodeDrop::Into(GroupId(2))));
assert_eq!(store.session(SessionId(1)).unwrap().group, Some(GroupId(2)));
assert!(store.move_node_unnotified(web, NodeDrop::Root));
assert_eq!(store.session(SessionId(1)).unwrap().group, None);
}
#[test]
fn dragging_a_group_refuses_its_descendants() {
let mut store = SessionStore::empty();
let parent = store.insert_group_unnotified(GroupDraft::new("parent", None));
let child = store.insert_group_unnotified(GroupDraft::new("child", Some(parent)));
let peer = store.insert_group_unnotified(GroupDraft::new("peer", None));
assert!(!store.move_node_unnotified(SessionNode::Group(parent), NodeDrop::Into(child)));
assert!(!store.move_node_unnotified(SessionNode::Group(parent), NodeDrop::Into(parent)));
assert!(store.move_node_unnotified(
SessionNode::Group(peer),
NodeDrop::Before(SessionNode::Group(parent))
));
assert!(store.group(peer).unwrap().sort_order < store.group(parent).unwrap().sort_order);
}
#[test]
fn load_resumes_the_id_sequences_and_the_recent_order() {
let database = SessionDatabase::in_memory().unwrap();
database
.insert_group(&SessionGroup::new(
GroupId(4),
GroupDraft::new("生产", None),
))
.unwrap();
database
.insert_session(&Session::new(
SessionId(7),
draft("web-01", Some(GroupId(4))),
))
.unwrap();
database
.insert_session(&Session::new(SessionId(9), draft("db-01", None)))
.unwrap();
database.touch_connected(SessionId(9), 100).unwrap();
let mut store = SessionStore::load(database).unwrap();
assert_eq!(store.groups().len(), 1);
assert_eq!(
store.recent_sessions().map(|s| s.id).collect::<Vec<_>>(),
[SessionId(9)]
);
// Nothing is connected on load, however recently it last connected.
assert!(store.sessions().iter().all(|s| !s.state.is_connected()));
assert_eq!(
store.insert_group_unnotified(GroupDraft::new("测试", None)),
GroupId(5)
);
assert_eq!(store.insert_unnotified(draft("new", None)), SessionId(10));
}
}