use crate::{
core::extensions::{
self, DockSpan, ExtensionUiRequest, OverlayEvent, OverlayKey, OverlaySize, OverlayView,
UiNotifyLevel, next_ui_id,
},
utils::paths::expand_tilde_path,
};
use std::{
collections::HashMap,
sync::{Mutex, OnceLock},
time::Duration,
};
use tokio::sync::oneshot;
const PROMPT_TIMEOUT: Duration = Duration::from_secs(180);
static PROMPT: OnceLock<Mutex<Option<PromptState>>> = OnceLock::new();
static SECRETS: OnceLock<Mutex<HashMap<(String, SecretKind), String>>> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SecretKind {
Password,
KeyPassphrase,
}
impl SecretKind {
fn env_var(self) -> &'static str {
match self {
SecretKind::Password => "PRUX_SSH_PASSWORD",
SecretKind::KeyPassphrase => "PRUX_SSH_KEY_PASSPHRASE",
}
}
}
fn secrets() -> &'static Mutex<HashMap<(String, SecretKind), String>> {
SECRETS.get_or_init(|| Mutex::new(HashMap::new()))
}
struct PromptState {
id: u64,
kind: SecretKind,
scope: String,
prompt: String,
buffer: String,
reply: Option<oneshot::Sender<Option<String>>>,
}
fn prompt_slot() -> &'static Mutex<Option<PromptState>> {
PROMPT.get_or_init(|| Mutex::new(None))
}
pub fn cached(scope: &str, kind: SecretKind) -> Option<String> {
secrets()
.lock()
.unwrap()
.get(&(scope.to_string(), kind))
.cloned()
}
pub fn remember(scope: &str, kind: SecretKind, value: &str) {
secrets()
.lock()
.unwrap()
.insert((scope.to_string(), kind), value.to_string());
}
pub fn forget(scope: &str) -> usize {
let mut map = secrets().lock().unwrap();
let before = map.len();
map.retain(|(key, _), _| key != scope);
before - map.len()
}
pub fn forget_all() -> usize {
let mut map = secrets().lock().unwrap();
let count = map.len();
map.clear();
count
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Acquired {
Found(String),
Missing,
Cancelled,
Rejected(String),
}
pub async fn acquire(
scope: &str,
kind: SecretKind,
prompt: &str,
password_file: Option<&str>,
) -> Acquired {
if let Some(value) = cached(scope, kind) {
return Acquired::Found(value);
}
if let Some(path) = password_file {
match read_secret_file(path) {
Ok(value) => {
remember(scope, kind, &value);
return Acquired::Found(value);
}
Err(e) => return Acquired::Rejected(e),
}
}
if let Ok(value) = std::env::var(kind.env_var())
&& !value.is_empty()
{
remember(scope, kind, &value);
return Acquired::Found(value);
}
if !extensions::ui_ready() {
return Acquired::Missing;
}
match ask_overlay(scope, kind, prompt).await {
Acquired::Found(value) if !value.is_empty() => {
remember(scope, kind, &value);
Acquired::Found(value)
}
Acquired::Found(_) => Acquired::Missing,
other => other,
}
}
pub fn read_secret_file(path: &str) -> Result<String, String> {
let expanded = expand_tilde_path(path.to_string());
let meta = std::fs::metadata(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(format!(
"{} has mode {:03o}; run `chmod 600 {}`",
expanded.display(),
mode,
expanded.display()
));
}
}
#[cfg(not(unix))]
let _ = &meta;
let text =
std::fs::read_to_string(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?;
let first = text.lines().next().unwrap_or("").trim();
if first.is_empty() {
return Err(format!("{} is empty", expanded.display()));
}
Ok(first.to_string())
}
async fn ask_overlay(scope: &str, kind: SecretKind, prompt: &str) -> Acquired {
let id = next_ui_id();
let (tx, rx) = oneshot::channel();
{
let mut slot = prompt_slot().lock().unwrap();
if slot.is_some() {
return Acquired::Missing;
}
*slot = Some(PromptState {
id,
kind,
scope: scope.to_string(),
prompt: prompt.to_string(),
buffer: String::new(),
reply: Some(tx),
});
}
extensions::request_ui(ExtensionUiRequest::ShowOverlay { id });
let result = tokio::time::timeout(PROMPT_TIMEOUT, rx).await;
match result {
Ok(Ok(Some(value))) => Acquired::Found(value),
Ok(Ok(None)) => {
extensions::request_ui(ExtensionUiRequest::Notify {
text: "SSH authentication cancelled".to_string(),
level: UiNotifyLevel::Warning,
});
Acquired::Cancelled
}
_ => {
prompt_slot().lock().unwrap().take();
extensions::request_ui(ExtensionUiRequest::HideOverlay { id });
if !extensions::ui_ready() {
return Acquired::Missing;
}
extensions::request_ui(ExtensionUiRequest::Notify {
text: format!("SSH credential prompt timed out ({prompt})"),
level: UiNotifyLevel::Warning,
});
Acquired::Missing
}
}
}
pub fn overlay_view(id: u64, _cols: u16) -> Option<OverlayView> {
let slot = prompt_slot().lock().unwrap();
let state = slot.as_ref().filter(|s| s.id == id)?;
let masked = "*".repeat(state.buffer.chars().count());
Some(OverlayView {
title: "SSH authentication".to_string(),
header: vec![
vec![DockSpan::new("accent", "#8abeb7", " SSH authentication")],
vec![DockSpan::plain("")],
vec![DockSpan::plain(state.prompt.clone())],
],
lines: vec![vec![DockSpan::new("accent", "#8abeb7", masked)]],
messages: Vec::new(),
footer: vec![
("Enter".to_string(), "confirm".to_string()),
("Esc".to_string(), "cancel".to_string()),
],
input: None,
editor: None,
size: OverlaySize::Panel { max_rows: 10 },
selected: None,
input_focus: false,
})
}
pub fn on_overlay_event(id: u64, ev: &OverlayEvent) -> bool {
let mut slot = prompt_slot().lock().unwrap();
let Some(state) = slot.as_mut().filter(|s| s.id == id) else {
return false;
};
match ev {
OverlayEvent::Key { key, .. } => match key {
OverlayKey::Char(c) => {
state.buffer.push(*c);
true
}
OverlayKey::Backspace => {
state.buffer.pop();
true
}
OverlayKey::Delete => {
state.buffer.clear();
true
}
OverlayKey::Enter => {
let (kind, scope) = (state.kind, state.scope.clone());
let buffer = std::mem::take(&mut state.buffer);
let reply = state.reply.take();
drop(slot);
if let Some(reply) = reply {
_ = reply.send(Some(buffer.clone()));
}
extensions::request_ui(ExtensionUiRequest::HideOverlay { id });
if !buffer.is_empty() {
remember(&scope, kind, &buffer);
}
true
}
OverlayKey::Esc => {
let reply = state.reply.take();
drop(slot);
if let Some(reply) = reply {
_ = reply.send(None);
}
prompt_slot().lock().unwrap().take();
extensions::request_ui(ExtensionUiRequest::HideOverlay { id });
true
}
_ => true,
},
OverlayEvent::Closed => {
let reply = state.reply.take();
drop(slot);
if let Some(reply) = reply {
_ = reply.send(None);
}
prompt_slot().lock().unwrap().take();
true
}
_ => false,
}
}
#[cfg(test)]
pub static AUTH_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_scopes_kinds_and_forgets() {
let _l = AUTH_TEST_LOCK.blocking_lock();
forget_all();
let scope = "root@cache-test:22";
assert!(cached(scope, SecretKind::Password).is_none());
remember(scope, SecretKind::Password, "s3cret");
remember(scope, SecretKind::KeyPassphrase, "pass");
assert_eq!(
cached(scope, SecretKind::Password).as_deref(),
Some("s3cret")
);
assert_eq!(
cached(scope, SecretKind::KeyPassphrase).as_deref(),
Some("pass")
);
remember("other@host:22", SecretKind::Password, "x");
assert_eq!(forget(scope), 2);
assert!(cached(scope, SecretKind::Password).is_none());
assert_eq!(
cached("other@host:22", SecretKind::Password).as_deref(),
Some("x")
);
assert_eq!(forget_all(), 1);
assert!(cached("other@host:22", SecretKind::Password).is_none());
}
#[test]
fn prompt_panel_takes_over_the_input_area() {
let _l = AUTH_TEST_LOCK.blocking_lock();
let (tx, _rx) = oneshot::channel();
*prompt_slot().lock().unwrap() = Some(PromptState {
id: 7,
kind: SecretKind::Password,
scope: "blue@ubuntu:22".to_string(),
prompt: "password for blue@ubuntu:22".to_string(),
buffer: "abc".to_string(),
reply: Some(tx),
});
let view = overlay_view(7, 80).expect("进行中应有覆盖层");
assert_eq!(view.size, OverlaySize::Panel { max_rows: 10 });
assert!(
view.header
.iter()
.any(|line| line.iter().any(|s| s.text.contains("SSH authentication"))),
"面板无标题行,标题要塞进固定区"
);
assert!(
view.header
.iter()
.any(|line| line.iter().any(|s| s.text.contains("blue@ubuntu:22"))),
"固定区要写清这是谁的口令"
);
let title: String = view.header[0].iter().map(|s| s.text.as_str()).collect();
assert_eq!(title, " SSH authentication", "标题独占首行");
assert!(
view.header[1].iter().all(|s| s.text.is_empty()),
"标题与口令提示之间留一行空行"
);
let prompt: String = view.header[2].iter().map(|s| s.text.as_str()).collect();
assert_eq!(prompt, "password for blue@ubuntu:22", "口令提示另起一行");
assert_eq!(view.lines[0][0].text, "***", "密码必须掩码");
prompt_slot().lock().unwrap().take();
}
#[test]
fn secret_file_rejects_wide_permissions() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("prux-secret-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("pw");
std::fs::write(&path, "hunter2\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let err = read_secret_file(&path.to_string_lossy()).unwrap_err();
assert!(err.contains("chmod 600"), "{err}");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert_eq!(
read_secret_file(&path.to_string_lossy()).unwrap(),
"hunter2"
);
_ = std::fs::remove_dir_all(&dir);
}
}
#[tokio::test]
async fn reads_password_from_file() {
let _l = AUTH_TEST_LOCK.lock().await;
let dir = std::env::temp_dir().join(format!("prux-acq-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("pw");
std::fs::write(&path, "file-secret").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
forget_all();
let value = acquire(
"u@acq:1",
SecretKind::Password,
"password",
Some(&path.to_string_lossy()),
)
.await;
assert_eq!(value, Acquired::Found("file-secret".to_string()));
assert_eq!(
cached("u@acq:1", SecretKind::Password).as_deref(),
Some("file-secret")
);
forget_all();
_ = std::fs::remove_dir_all(&dir);
}
}