/
magnusroot
/
komiterm
Обзор
Документация
Войти
/
magnusroot
/
komiterm
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/sshconfig.rs
225 строк
10 KB
Magnus Root
Fix render
17 июл 2026, 09:30
17 июл 2026, 09:30
ff9a0aa
Код
Авторство
О чём код?
use anyhow::Result; use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; /// Разобранные директивы для одного хоста после слияния всех подходящих /// Host-блоков — то, что реально пригодится для подключения. #[derive(Debug, Default, Clone)] pub struct HostEntry { pub hostname: Option<String>, pub user: Option<String>, pub port: Option<u16>, pub identity_files: Vec<PathBuf>, } /// Распарсенный ~/.ssh/config целиком (включая всё, что подтянуто через /// Include) — список (паттерны Host, директивы). Порядок блоков сохраняется, /// потому что в OpenSSH первое встреченное значение параметра побеждает — /// так же делаем и мы в `lookup`. pub struct SshConfig { blocks: Vec<(Vec<String>, HostEntry)>, } /// Предел глубины Include — защита от случайного (или специально /// подстроенного) цикла a.conf включает b.conf включает a.conf. const MAX_INCLUDE_DEPTH: usize = 16; impl SshConfig { /// Путь к пользовательскому конфигу. На Linux/macOS/Windows OpenSSH /// (включая встроенный в Windows 10+) использует один и тот же /// относительный путь `.ssh/config` от домашней директории — /// поэтому специальной ветки под Windows не нужно. pub fn default_path() -> Result<PathBuf> { let dirs = directories::UserDirs::new() .ok_or_else(|| anyhow::anyhow!("не удалось определить домашнюю директорию"))?; Ok(dirs.home_dir().join(".ssh").join("config")) } pub fn load_default() -> Result<Self> { let path = Self::default_path()?; if !path.exists() { return Ok(Self { blocks: Vec::new() }); } Self::load(&path) } pub fn load(path: &Path) -> Result<Self> { let mut blocks = Vec::new(); let mut visited = HashSet::new(); parse_file(path, &mut blocks, &mut visited, 0)?; Ok(Self { blocks }) } /// Слить директивы всех Host-блоков, чьи паттерны матчат алиас, /// в одну запись. Первое встреченное значение каждого поля побеждает /// (поведение OpenSSH), а IdentityFile — накапливаются из всех блоков. pub fn lookup(&self, alias: &str) -> HostEntry { let mut result = HostEntry::default(); for (patterns, entry) in &self.blocks { if !patterns.iter().any(|p| host_pattern_matches(p, alias)) { continue; } if result.hostname.is_none() { result.hostname.clone_from(&entry.hostname); } if result.user.is_none() { result.user.clone_from(&entry.user); } if result.port.is_none() { result.port = entry.port; } result.identity_files.extend(entry.identity_files.iter().cloned()); } result } } /// Разобрать один файл конфигурации, дописывая блоки в общий `blocks` по /// мере встречи (включая рекурсивно всё, что подтянуто через Include — /// OpenSSH ведёт себя так же: как будто содержимое файла подставлено на /// место директивы Include). fn parse_file( path: &Path, blocks: &mut Vec<(Vec<String>, HostEntry)>, visited: &mut HashSet<PathBuf>, depth: usize, ) -> Result<()> { if depth > MAX_INCLUDE_DEPTH { return Ok(()); // молча обрываем цикл — лучше неполный конфиг, чем зависание } let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); if !visited.insert(canonical) { return Ok(()); // этот файл уже разобран где-то выше по цепочке Include } let Ok(content) = fs::read_to_string(path) else { return Ok(()); // Include на несуществующий файл — OpenSSH тоже это терпит }; let base_dir = path.parent().map(Path::to_path_buf); let mut current: Option<(Vec<String>, HostEntry)> = None; for raw_line in content.lines() { let line = raw_line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let Some((key, value)) = line.split_once(char::is_whitespace) else { continue; }; let key = key.trim().to_ascii_lowercase(); let value = value.trim(); if key == "include" { for resolved in resolve_include_pattern(value, base_dir.as_deref()) { parse_file(&resolved, blocks, visited, depth + 1)?; } continue; } if key == "host" { if let Some(block) = current.take() { blocks.push(block); } let patterns = value.split_whitespace().map(String::from).collect(); current = Some((patterns, HostEntry::default())); continue; } // Match-блоки пока не поддержаны — см. TODO внизу файла. let Some((_, entry)) = current.as_mut() else { continue; }; match key.as_str() { "hostname" => entry.hostname = Some(value.to_string()), "user" => entry.user = Some(value.to_string()), "port" => entry.port = value.parse().ok(), "identityfile" => entry.identity_files.push(expand_tilde(value)), _ => {} } } if let Some(block) = current.take() { blocks.push(block); } Ok(()) } /// Раскрывает значение директивы Include в список реальных файлов. /// Поддержано: абсолютные пути, "~/"-пути, относительные (считаются /// относительно директории текущего конфига — как в OpenSSH), и один /// '*' как wildcard в имени файла (без вложенных ** и без [classes]). /// Несколько паттернов через пробел на одной строке — тоже как в OpenSSH. fn resolve_include_pattern(value: &str, base_dir: Option<&Path>) -> Vec<PathBuf> { let mut results = Vec::new(); for raw_pattern in value.split_whitespace() { let expanded = expand_tilde(raw_pattern); let pattern_path = if expanded.is_absolute() { expanded } else if let Some(base) = base_dir { base.join(expanded) } else { expanded }; if !raw_pattern.contains('*') { if pattern_path.is_file() { results.push(pattern_path); } continue; } let Some(dir) = pattern_path.parent() else { continue }; let Some(file_pattern) = pattern_path.file_name().and_then(|n| n.to_str()) else { continue; }; let Ok(read_dir) = fs::read_dir(dir) else { continue }; let mut matched: Vec<PathBuf> = read_dir .filter_map(|entry| entry.ok()) .map(|entry| entry.path()) .filter(|p| p.is_file()) .filter(|p| { p.file_name() .and_then(|n| n.to_str()) .is_some_and(|name| host_pattern_matches(file_pattern, name)) }) .collect(); // OpenSSH обходит совпадения glob в отсортированном порядке — // без этого порядок Include был бы недетерминированным (порядок // read_dir не гарантирован), а порядок важен из-за "первый выигрывает". matched.sort(); results.extend(matched); } results } /// Раскрывает "~/" в начале значения IdentityFile/Include в домашнюю директорию. fn expand_tilde(value: &str) -> PathBuf { let rest = value.strip_prefix("~/").or_else(|| value.strip_prefix("~\\")); if let Some(rest) = rest { if let Some(dirs) = directories::UserDirs::new() { return dirs.home_dir().join(rest); } } PathBuf::from(value) } /// Сопоставление простого паттерна с '*' как wildcard: используется и для /// Host-паттернов, и для имён файлов в Include. Настоящий ssh_config умеет /// больше (?, [abc], отрицания через !) — для MVP этого достаточно для /// подавляющего большинства реальных конфигов. fn host_pattern_matches(pattern: &str, value: &str) -> bool { if pattern == "*" { return true; } if let Some(prefix) = pattern.strip_suffix('*') { return value.starts_with(prefix); } pattern == value } // TODO: // - Match-блоки // - полноценные glob-паттерны и отрицания в Host/Include (?, [abc], !) // - системный /etc/ssh/ssh_config (Linux/macOS) и %ProgramData%\ssh\ssh_config // (Windows) как fallback с приоритетом ниже пользовательского конфига