Files
openidea/crates/ide-db/src/secrets.rs
Fabio Nunes ad27a7064a fix(security): keep saved passwords in the OS credential store (#910)
SSH passwords and key passphrases, git HTTPS credentials, the HTTP proxy
password and data source passwords were kept in plaintext JSON files in
the app data directory. They now live in the OS credential store via
keyring 4 (macOS Keychain, Windows Credential Manager, Secret Service on
Linux); each file keeps only an index of keys under a per-directory id.

Files from older versions are moved key by key on first use, with a
.migrating sidecar so a failed or interrupted move retries without
duplicating credentials. Without a credential store (headless Linux)
the old file is still read but nothing new is written to disk; the
secret lasts for the session. New data sources no longer save their
password or SSH tunnel secret unless asked.

Also keeps the index path in SecretStore for global data sources and
runs their test against the in-memory store.

Fixes #740 (credentials part)

Co-authored-by: LittleGuava <[email protected]>
2026-11-05 09:47:19 +08:00

86 lines
2.8 KiB
Rust

//! Saved data source passwords, in the OS credential store like the other saved
//! passwords ([`SecretFile`]). One index file for all projects; each credential is
//! keyed by project root, then credential key.
use ide_core::IdeResult;
use ide_ssh::secrets::SecretFile;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub struct SecretStore {
path: PathBuf,
file: SecretFile,
project: String,
}
/// Credential key of the SSH password / key passphrase of data source `id`.
pub fn ssh_key(id: &str) -> String {
format!("{id}#ssh")
}
fn file_key(project: &str, key: &str) -> String {
format!("{project}|{key}")
}
/// Older versions' file: project root → credential key → secret.
fn legacy(value: serde_json::Value) -> Option<BTreeMap<String, String>> {
let all: BTreeMap<String, BTreeMap<String, String>> = serde_json::from_value(value).ok()?;
Some(
all.into_iter()
.flat_map(|(project, keys)| keys.into_iter().map(move |(k, s)| (file_key(&project, &k), s)))
.collect(),
)
}
impl SecretStore {
pub fn new(path: PathBuf, root: &Path) -> Self {
let file = SecretFile::with_legacy(path.clone(), legacy);
Self { path, file, project: root.to_string_lossy().into_owned() }
}
/// The same file, keyed by `scope` instead (global data sources: the app data directory).
pub fn scoped(&self, scope: &Path) -> Self {
Self::new(self.path.clone(), scope)
}
pub fn get(&self, key: &str) -> Option<String> {
self.file.get(&file_key(&self.project, key))
}
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
self.file.set(&file_key(&self.project, key), secret)
}
pub fn delete(&self, key: &str) {
self.file.delete(&file_key(&self.project, key))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_projects_apart_and_drops_empty_ones() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("db-passwords.json");
let a = SecretStore::new(file.clone(), Path::new("/p/a"));
let b = SecretStore::new(file.clone(), Path::new("/p/b"));
a.set("s1", "pw-a").unwrap();
b.set("s1", "pw-b").unwrap();
b.set(&ssh_key("s1"), "pass").unwrap();
assert_eq!(a.get("s1").as_deref(), Some("pw-a"));
assert_eq!(b.get("s1#ssh").as_deref(), Some("pass"));
a.delete("s1");
assert_eq!(a.get("s1"), None);
assert_eq!(b.get("s1").as_deref(), Some("pw-b"));
assert!(!std::fs::read_to_string(&file).unwrap().contains("/p/a"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600);
}
}
}