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