Files
shell-rs/src/session/database.rs
T

729 lines
27 KiB
Rust

//! The SQLite file behind the session store.
//!
//! Everything here is synchronous and runs on the UI thread: the writes are
//! single rows triggered by a dialog the user just confirmed, not a stream.
//! The store keeps memory as the source of truth and mirrors each change
//! here, so a failed write costs the user persistence, never the edit.
use std::path::Path;
use rusqlite::{Connection, params};
use super::{
AuthKind, BookmarkSide, GroupDraft, GroupId, HostOs, Session, SessionDraft, SessionGroup,
SessionId,
};
/// SQLite's only integer type is `i64`, so the `u64` ids cross the boundary
/// as `i64`. They are counters that start at 1 and never come near the range
/// where the round trip would lose anything.
fn to_sql(id: u64) -> i64 {
id as i64
}
fn from_sql(id: i64) -> u64 {
id as u64
}
/// Bumped whenever `migrate` gains a step. Stored in `PRAGMA user_version`.
const SCHEMA_VERSION: i64 = 6;
const SCHEMA_V1: &str = "\
BEGIN;
CREATE TABLE groups (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES groups(id) ON DELETE CASCADE
);
CREATE TABLE sessions (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
auth TEXT NOT NULL,
group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
last_connected_at INTEGER
);
CREATE INDEX sessions_group_id ON sessions(group_id);
PRAGMA user_version = 1;
COMMIT;";
const SCHEMA_V2: &str = "\
BEGIN;
ALTER TABLE sessions ADD COLUMN key_path TEXT;
UPDATE sessions SET auth = 'auto' WHERE auth = 'key';
PRAGMA user_version = 2;
COMMIT;";
const SCHEMA_V3: &str = "\
BEGIN;
ALTER TABLE sessions ADD COLUMN os TEXT;
PRAGMA user_version = 3;
COMMIT;";
const SCHEMA_V4: &str = "\
BEGIN;
ALTER TABLE groups ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sessions ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
UPDATE groups SET sort_order = id;
UPDATE sessions SET sort_order = id;
PRAGMA user_version = 4;
COMMIT;";
const SCHEMA_V5: &str = "\
BEGIN;
ALTER TABLE groups ADD COLUMN expanded INTEGER NOT NULL DEFAULT 1;
PRAGMA user_version = 5;
COMMIT;";
/// Per-session SFTP bookmarks, one list per pane. The cascade removes them
/// with their session, the way WinSCP drops a site's bookmarks with the site.
const SCHEMA_V6: &str = "\
BEGIN;
CREATE TABLE bookmarks (
id INTEGER PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
side TEXT NOT NULL CHECK (side IN ('local', 'remote')),
path TEXT NOT NULL,
sort_order INTEGER NOT NULL,
UNIQUE (session_id, side, path)
);
PRAGMA user_version = 6;
COMMIT;";
/// Everything one launch reads back from disk.
pub struct StoredData {
/// Groups in id order; `parent` and `sort_order` give the visible tree.
pub groups: Vec<SessionGroup>,
/// Sessions in id order, all of them disconnected.
pub sessions: Vec<Session>,
/// Sessions that have ever connected, most recently connected first.
pub recent: Vec<SessionId>,
/// SFTP bookmarks in the order they were added.
pub bookmarks: Vec<(SessionId, BookmarkSide, String)>,
}
pub struct SessionDatabase {
connection: Connection,
}
impl SessionDatabase {
/// Open (creating it if needed) the database at `path` and migrate it.
pub fn open(path: &Path) -> rusqlite::Result<Self> {
Self::prepare(Connection::open(path)?)
}
/// An anonymous database that lives only as long as this value. Used by
/// tests, and as the fallback when the real file cannot be opened.
pub fn in_memory() -> rusqlite::Result<Self> {
Self::prepare(Connection::open_in_memory()?)
}
fn prepare(mut connection: Connection) -> rusqlite::Result<Self> {
// Cascading deletes are how removing a group removes its subtree, and
// SQLite only honours the foreign keys when asked, per connection.
connection.pragma_update(None, "foreign_keys", true)?;
connection.execute_batch("PRAGMA journal_mode = WAL;")?;
migrate(&mut connection)?;
Ok(Self { connection })
}
pub fn load(&self) -> rusqlite::Result<StoredData> {
let groups = self
.connection
.prepare("SELECT id, name, parent_id, sort_order, expanded FROM groups ORDER BY id")?
.query_map([], |row| {
let id: i64 = row.get(0)?;
let name: String = row.get(1)?;
let parent: Option<i64> = row.get(2)?;
let mut group = SessionGroup::new(
GroupId(from_sql(id)),
GroupDraft::new(name, parent.map(|id| GroupId(from_sql(id)))),
);
group.sort_order = row.get(3)?;
group.expanded = row.get(4)?;
Ok(group)
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let sessions = self
.connection
.prepare(
"SELECT id, name, host, port, username, auth, group_id, key_path, os, sort_order \
FROM sessions ORDER BY id",
)?
.query_map([], |row| {
let id: i64 = row.get(0)?;
let name: String = row.get(1)?;
let host: String = row.get(2)?;
let port: u16 = row.get(3)?;
let user: String = row.get(4)?;
let auth: String = row.get(5)?;
let group: Option<i64> = row.get(6)?;
let key_path: Option<String> = row.get(7)?;
let os: Option<String> = row.get(8)?;
let mut session = Session::new(
SessionId(from_sql(id)),
SessionDraft::new(
name,
host,
port,
user,
AuthKind::from_stored(&auth),
group.map(|id| GroupId(from_sql(id))),
)
.with_optional_key_path(key_path),
);
session.os = os.as_deref().and_then(HostOs::from_stored);
session.sort_order = row.get(9)?;
Ok(session)
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let recent = self
.connection
.prepare(
"SELECT id FROM sessions WHERE last_connected_at IS NOT NULL \
ORDER BY last_connected_at DESC, id DESC",
)?
.query_map([], |row| {
row.get::<_, i64>(0).map(|id| SessionId(from_sql(id)))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let bookmarks = self
.connection
.prepare("SELECT session_id, side, path FROM bookmarks ORDER BY sort_order, id")?
.query_map([], |row| {
let session: i64 = row.get(0)?;
let side: String = row.get(1)?;
Ok((
SessionId(from_sql(session)),
BookmarkSide::from_stored(&side),
row.get::<_, String>(2)?,
))
})?
.filter_map(|row| match row {
Ok((session, Some(side), path)) => Some(Ok((session, side, path))),
Ok((_, None, _)) => None,
Err(error) => Some(Err(error)),
})
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(StoredData {
groups,
sessions,
recent,
bookmarks,
})
}
pub fn insert_group(&self, group: &SessionGroup) -> rusqlite::Result<()> {
self.connection.execute(
"INSERT INTO groups (id, name, parent_id, sort_order, expanded) VALUES (?1, ?2, ?3, ?4, ?5)",
params![
to_sql(group.id.0),
group.name.as_ref(),
group.parent.map(|parent| to_sql(parent.0)),
group.sort_order,
group.expanded,
],
)?;
Ok(())
}
pub fn update_group(&self, group: &SessionGroup) -> rusqlite::Result<()> {
self.connection.execute(
"UPDATE groups SET name = ?2, parent_id = ?3, sort_order = ?4, expanded = ?5 WHERE id = ?1",
params![
to_sql(group.id.0),
group.name.as_ref(),
group.parent.map(|parent| to_sql(parent.0)),
group.sort_order,
group.expanded,
],
)?;
Ok(())
}
/// Save the session tree's expansion choice for one group.
pub fn set_group_expanded(&self, id: GroupId, expanded: bool) -> rusqlite::Result<()> {
self.connection.execute(
"UPDATE groups SET expanded = ?2 WHERE id = ?1",
params![to_sql(id.0), expanded],
)?;
Ok(())
}
/// Save an expand-all or collapse-all command in one database write.
pub fn set_all_groups_expanded(&self, expanded: bool) -> rusqlite::Result<()> {
self.connection
.execute("UPDATE groups SET expanded = ?1", params![expanded])?;
Ok(())
}
/// Deleting a group takes its subgroups and their sessions with it, via
/// the `ON DELETE CASCADE` on both foreign keys.
pub fn remove_group(&self, id: GroupId) -> rusqlite::Result<()> {
self.connection
.execute("DELETE FROM groups WHERE id = ?1", params![to_sql(id.0)])?;
Ok(())
}
pub fn insert_session(&self, session: &Session) -> rusqlite::Result<()> {
self.connection.execute(
"INSERT INTO sessions (id, name, host, port, username, auth, group_id, key_path, os, sort_order) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
to_sql(session.id.0),
session.name.as_ref(),
session.host.as_ref(),
session.port,
session.user.as_ref(),
session.auth.as_str(),
session.group.map(|group| to_sql(group.0)),
session.key_path.as_deref(),
session.os.map(HostOs::as_str),
session.sort_order,
],
)?;
Ok(())
}
/// Rewrites the editable fields. `last_connected_at` and `os` are left
/// alone: they are written by `touch_connected` and `set_host_os`, and
/// neither is part of the session form.
pub fn update_session(&self, session: &Session) -> rusqlite::Result<()> {
self.connection.execute(
"UPDATE sessions SET name = ?2, host = ?3, port = ?4, username = ?5, \
auth = ?6, group_id = ?7, key_path = ?8, sort_order = ?9 WHERE id = ?1",
params![
to_sql(session.id.0),
session.name.as_ref(),
session.host.as_ref(),
session.port,
session.user.as_ref(),
session.auth.as_str(),
session.group.map(|group| to_sql(group.0)),
session.key_path.as_deref(),
session.sort_order,
],
)?;
Ok(())
}
pub fn remove_session(&self, id: SessionId) -> rusqlite::Result<()> {
self.connection
.execute("DELETE FROM sessions WHERE id = ?1", params![to_sql(id.0)])?;
Ok(())
}
/// Save one drag as a unit, including parent changes and sibling order.
pub fn save_tree_order(
&self,
groups: &[SessionGroup],
sessions: &[Session],
) -> rusqlite::Result<()> {
let transaction = self.connection.unchecked_transaction()?;
{
let mut update_group = transaction
.prepare("UPDATE groups SET parent_id = ?2, sort_order = ?3 WHERE id = ?1")?;
for group in groups {
update_group.execute(params![
to_sql(group.id.0),
group.parent.map(|id| to_sql(id.0)),
group.sort_order,
])?;
}
let mut update_session = transaction
.prepare("UPDATE sessions SET group_id = ?2, sort_order = ?3 WHERE id = ?1")?;
for session in sessions {
update_session.execute(params![
to_sql(session.id.0),
session.group.map(|id| to_sql(id.0)),
session.sort_order,
])?;
}
}
transaction.commit()
}
/// Append a bookmark; adding one that exists is a no-op.
pub fn insert_bookmark(
&self,
session: SessionId,
side: BookmarkSide,
path: &str,
) -> rusqlite::Result<()> {
self.connection.execute(
"INSERT OR IGNORE INTO bookmarks (session_id, side, path, sort_order) \
VALUES (?1, ?2, ?3, (SELECT COALESCE(MAX(sort_order), 0) + 1 FROM bookmarks))",
params![to_sql(session.0), side.as_str(), path],
)?;
Ok(())
}
pub fn remove_bookmark(
&self,
session: SessionId,
side: BookmarkSide,
path: &str,
) -> rusqlite::Result<()> {
self.connection.execute(
"DELETE FROM bookmarks WHERE session_id = ?1 AND side = ?2 AND path = ?3",
params![to_sql(session.0), side.as_str(), path],
)?;
Ok(())
}
/// Record the operating system a probe found on the host. `None` clears
/// it, which is what a failed probe on a rebuilt host leaves behind.
pub fn set_host_os(&self, id: SessionId, os: Option<HostOs>) -> rusqlite::Result<()> {
self.connection.execute(
"UPDATE sessions SET os = ?2 WHERE id = ?1",
params![to_sql(id.0), os.map(HostOs::as_str)],
)?;
Ok(())
}
/// Record that a session just connected, which is what orders the start
/// page's recent list across launches.
pub fn touch_connected(&self, id: SessionId, at: u64) -> rusqlite::Result<()> {
self.connection.execute(
"UPDATE sessions SET last_connected_at = ?2 WHERE id = ?1",
params![to_sql(id.0), to_sql(at)],
)?;
Ok(())
}
}
fn migrate(connection: &mut Connection) -> rusqlite::Result<()> {
let version: i64 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
if version >= SCHEMA_VERSION {
return Ok(());
}
if version < 1 {
connection.execute_batch(SCHEMA_V1)?;
}
if version < 2 {
connection.execute_batch(SCHEMA_V2)?;
}
if version < 3 {
connection.execute_batch(SCHEMA_V3)?;
}
if version < 4 {
connection.execute_batch(SCHEMA_V4)?;
}
if version < 5 {
connection.execute_batch(SCHEMA_V5)?;
}
if version < 6 {
connection.execute_batch(SCHEMA_V6)?;
}
Ok(())
}
/// Seconds since the Unix epoch, for `touch_connected`.
pub fn now_seconds() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn group(id: u64, name: &str, parent: Option<u64>) -> SessionGroup {
SessionGroup::new(GroupId(id), GroupDraft::new(name, parent.map(GroupId)))
}
fn session(id: u64, name: &str, group: Option<u64>) -> Session {
Session::new(
SessionId(id),
SessionDraft::new(
name,
"10.0.0.1",
22,
"root",
AuthKind::Password,
group.map(GroupId),
),
)
}
#[test]
fn a_fresh_database_is_empty_and_migrated() {
let db = SessionDatabase::in_memory().unwrap();
let version: i64 = db
.connection
.pragma_query_value(None, "user_version", |row| row.get(0))
.unwrap();
assert_eq!(version, SCHEMA_VERSION);
let data = db.load().unwrap();
assert!(data.groups.is_empty());
assert!(data.sessions.is_empty());
assert!(data.recent.is_empty());
}
#[test]
fn round_trips_nested_groups_and_sessions() {
let db = SessionDatabase::in_memory().unwrap();
db.insert_group(&group(1, "生产", None)).unwrap();
db.insert_group(&group(2, "数据库", Some(1))).unwrap();
db.insert_session(&session(1, "web-01", Some(1))).unwrap();
db.insert_session(&session(2, "db-01", Some(2))).unwrap();
// A session at the root of the tree, outside every group.
db.insert_session(&session(3, "jump", None)).unwrap();
let data = db.load().unwrap();
assert_eq!(data.groups.len(), 2);
assert_eq!(data.groups[1].parent, Some(GroupId(1)));
let names: Vec<_> = data.sessions.iter().map(|s| s.name.as_ref()).collect();
assert_eq!(names, ["web-01", "db-01", "jump"]);
assert_eq!(data.sessions[2].group, None);
assert_eq!(data.sessions[0].auth, AuthKind::Password);
// Connection state is runtime only: everything loads disconnected.
assert!(data.sessions.iter().all(|s| !s.state.is_connected()));
}
#[test]
fn updating_rewrites_the_editable_fields() {
let db = SessionDatabase::in_memory().unwrap();
db.insert_group(&group(1, "生产", None)).unwrap();
db.insert_group(&group(2, "测试", None)).unwrap();
db.insert_session(&session(1, "web-01", Some(1))).unwrap();
let mut moved = session(1, "web-01", Some(2));
moved.port = 2222;
moved.auth = AuthKind::Key;
moved.key_path = Some("/tmp/test-key".into());
db.update_session(&moved).unwrap();
db.update_group(&group(2, "预发", None)).unwrap();
let data = db.load().unwrap();
assert_eq!(data.sessions[0].port, 2222);
assert_eq!(data.sessions[0].auth, AuthKind::Key);
assert_eq!(data.sessions[0].key_path.as_deref(), Some("/tmp/test-key"));
assert_eq!(data.sessions[0].group, Some(GroupId(2)));
assert_eq!(data.groups[1].name.as_ref(), "预发");
}
#[test]
fn removing_a_group_cascades_to_subgroups_and_their_sessions() {
let db = SessionDatabase::in_memory().unwrap();
db.insert_group(&group(1, "生产", None)).unwrap();
db.insert_group(&group(2, "数据库", Some(1))).unwrap();
db.insert_group(&group(3, "测试", None)).unwrap();
db.insert_session(&session(1, "web-01", Some(1))).unwrap();
db.insert_session(&session(2, "db-01", Some(2))).unwrap();
db.insert_session(&session(3, "qa", Some(3))).unwrap();
db.insert_session(&session(4, "jump", None)).unwrap();
db.remove_group(GroupId(1)).unwrap();
let data = db.load().unwrap();
let groups: Vec<_> = data.groups.iter().map(|g| g.name.as_ref()).collect();
assert_eq!(groups, ["测试"]);
let sessions: Vec<_> = data.sessions.iter().map(|s| s.name.as_ref()).collect();
assert_eq!(sessions, ["qa", "jump"]);
}
#[test]
fn recent_lists_the_most_recently_connected_first() {
let db = SessionDatabase::in_memory().unwrap();
for id in 1..=3 {
db.insert_session(&session(id, &format!("s{id}"), None))
.unwrap();
}
db.touch_connected(SessionId(1), 100).unwrap();
db.touch_connected(SessionId(3), 300).unwrap();
// Session 2 never connected and stays out of the list.
assert_eq!(
db.load().unwrap().recent,
[SessionId(3), SessionId(1)],
"newest connection first"
);
db.touch_connected(SessionId(1), 400).unwrap();
assert_eq!(db.load().unwrap().recent, [SessionId(1), SessionId(3)]);
}
#[test]
fn reopening_the_same_file_keeps_the_data() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shellr.db");
{
let db = SessionDatabase::open(&path).unwrap();
db.insert_group(&group(1, "生产", None)).unwrap();
db.insert_session(&session(1, "web-01", Some(1))).unwrap();
db.touch_connected(SessionId(1), 42).unwrap();
}
let db = SessionDatabase::open(&path).unwrap();
let data = db.load().unwrap();
assert_eq!(data.groups.len(), 1);
assert_eq!(data.sessions[0].name.as_ref(), "web-01");
assert_eq!(data.recent, [SessionId(1)]);
}
#[test]
fn tree_order_and_parent_survive_reopening() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shellr.db");
{
let db = SessionDatabase::open(&path).unwrap();
db.insert_group(&group(1, "生产", None)).unwrap();
db.insert_group(&group(2, "测试", None)).unwrap();
db.insert_session(&session(1, "web", Some(1))).unwrap();
db.insert_session(&session(2, "db", Some(1))).unwrap();
let mut data = db.load().unwrap();
data.groups[1].parent = Some(GroupId(1));
data.groups[1].sort_order = 0;
data.sessions[0].group = Some(GroupId(2));
data.sessions[1].sort_order = 0;
db.save_tree_order(&data.groups, &data.sessions).unwrap();
}
let data = SessionDatabase::open(&path).unwrap().load().unwrap();
assert_eq!(data.groups[1].parent, Some(GroupId(1)));
assert_eq!(data.sessions[0].group, Some(GroupId(2)));
assert_eq!(data.sessions[1].sort_order, 0);
}
#[test]
fn v4_groups_start_expanded_and_then_keep_the_saved_choice() {
let connection = Connection::open_in_memory().unwrap();
connection.execute_batch(SCHEMA_V1).unwrap();
connection.execute_batch(SCHEMA_V2).unwrap();
connection.execute_batch(SCHEMA_V3).unwrap();
connection.execute_batch(SCHEMA_V4).unwrap();
connection
.execute("INSERT INTO groups (id, name) VALUES (1, '生产')", [])
.unwrap();
let database = SessionDatabase::prepare(connection).unwrap();
let mut group = database.load().unwrap().groups.remove(0);
assert!(group.expanded);
database.set_group_expanded(group.id, false).unwrap();
group.name = "生产环境".into();
group.expanded = false;
database.update_group(&group).unwrap();
let saved = database.load().unwrap().groups.remove(0);
assert_eq!(saved.name.as_ref(), "生产环境");
assert!(!saved.expanded);
}
#[test]
fn migrates_v1_key_auth_to_auto_and_adds_key_path_and_os() {
let connection = Connection::open_in_memory().unwrap();
connection.execute_batch(SCHEMA_V1).unwrap();
connection
.execute(
"INSERT INTO sessions (id, name, host, port, username, auth) \
VALUES (1, '旧会话', 'example.test', 22, 'root', 'key')",
[],
)
.unwrap();
let db = SessionDatabase::prepare(connection).unwrap();
let version: i64 = db
.connection
.pragma_query_value(None, "user_version", |row| row.get(0))
.unwrap();
assert_eq!(version, SCHEMA_VERSION);
let data = db.load().unwrap();
assert_eq!(data.sessions[0].auth, AuthKind::Auto);
assert_eq!(data.sessions[0].key_path, None);
assert_eq!(data.sessions[0].os, None, "老库里的会话还没探测过");
}
#[test]
fn the_detected_operating_system_is_read_back() {
let db = SessionDatabase::in_memory().unwrap();
let session = Session::new(
SessionId(1),
SessionDraft::new("web", "10.0.0.1", 22, "root", AuthKind::Auto, None),
);
db.insert_session(&session).unwrap();
assert_eq!(db.load().unwrap().sessions[0].os, None);
db.set_host_os(session.id, Some(HostOs::Ubuntu)).unwrap();
assert_eq!(db.load().unwrap().sessions[0].os, Some(HostOs::Ubuntu));
// Renaming must not disturb it: the form does not own this column.
let mut renamed = session.clone();
renamed.name = "web-01".into();
db.update_session(&renamed).unwrap();
assert_eq!(db.load().unwrap().sessions[0].os, Some(HostOs::Ubuntu));
db.set_host_os(session.id, None).unwrap();
assert_eq!(db.load().unwrap().sessions[0].os, None);
}
#[test]
fn v5_databases_gain_bookmarks_that_cascade_with_their_session() {
let connection = Connection::open_in_memory().unwrap();
for step in [SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5] {
connection.execute_batch(step).unwrap();
}
connection
.execute(
"INSERT INTO sessions (id, name, host, port, username, auth) \
VALUES (1, 'web', 'example.test', 22, 'root', 'auto'), \
(2, 'db', 'example.test', 22, 'root', 'auto')",
[],
)
.unwrap();
let db = SessionDatabase::prepare(connection).unwrap();
db.insert_bookmark(SessionId(1), BookmarkSide::Remote, "/var/log")
.unwrap();
db.insert_bookmark(SessionId(1), BookmarkSide::Local, "/Users/me")
.unwrap();
db.insert_bookmark(SessionId(1), BookmarkSide::Remote, "/etc")
.unwrap();
// Adding the same path twice keeps one bookmark in its first place.
db.insert_bookmark(SessionId(1), BookmarkSide::Remote, "/var/log")
.unwrap();
db.insert_bookmark(SessionId(2), BookmarkSide::Remote, "/srv")
.unwrap();
assert_eq!(
db.load().unwrap().bookmarks,
[
(SessionId(1), BookmarkSide::Remote, "/var/log".to_string()),
(SessionId(1), BookmarkSide::Local, "/Users/me".to_string()),
(SessionId(1), BookmarkSide::Remote, "/etc".to_string()),
(SessionId(2), BookmarkSide::Remote, "/srv".to_string()),
]
);
db.remove_bookmark(SessionId(1), BookmarkSide::Remote, "/etc")
.unwrap();
db.remove_session(SessionId(2)).unwrap();
assert_eq!(
db.load().unwrap().bookmarks,
[
(SessionId(1), BookmarkSide::Remote, "/var/log".to_string()),
(SessionId(1), BookmarkSide::Local, "/Users/me".to_string()),
]
);
}
#[test]
fn schema_never_contains_secret_columns() {
let db = SessionDatabase::in_memory().unwrap();
let mut statement = db
.connection
.prepare("PRAGMA table_info(sessions)")
.unwrap();
let columns = statement
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap();
assert!(columns.contains(&"key_path".to_string()));
assert!(!columns.iter().any(|column| {
column.contains("password") || column.contains("passphrase") || column.contains("otp")
}));
}
}