CH-J ToolsSSH کے ذریعے سرور کا انتظام
مینو
شائع شدہ سورس

CH-J Tools

ایپلیکیشن کی کسی مخصوص ریلیز کی ڈائریکٹریز اور فائلیں دیکھیں۔

سورس ZIP ڈاؤن لوڈ کریں
CH-J / Server Manager 1704 فائلیں
0.1.2-beta شامل کیا گیا: · بلڈ کیا گیا:
suggest.rs شامل کیا گیا: تاریخ درج نہیں 47.0 KB · 921 لائنیں
کوڈ فائل ڈاؤن لوڈ کریں
1//! Terminal autocomplete: recognises the distribution of the connected server and offers commands from the
2//! catalog compiled from `Matrix json` as text. Nothing here runs a suggestion; the typed text is never logged or stored.
4use crate::core::error::{CoreError, CoreResult};
5use crate::core::ssh::platform::detect_platform;
6use crate::core::ssh::session::{shell_quote, SessionManager};
7use crate::core::vault::VaultStore;
8use once_cell::sync::Lazy;
9use parking_lot::Mutex;
10use serde::Deserialize;
11use serde_json::{json, Value};
12use std::collections::{HashMap, HashSet};
13use std::sync::Arc;
14use std::time::{Duration, Instant};
16const MAX_INPUT: usize = 200;
17const MAX_RESULTS: usize = 8;
18const MIN_KEYWORD: usize = 3;
19const MAX_SERVER_COMMANDS: usize = 20_000;
20const LIST_TTL: Duration = Duration::from_secs(15);
21const MAX_LISTED_DIRECTORIES: usize = 128;
22const MAX_DIRECTORY_ENTRIES: usize = 5_000;
23const MAX_PATH_DEPTH: usize = 24;
24const MAX_DICTIONARY: usize = 500;
25const DICTIONARY_KEY: &str = "terminalDictionary";
26/// Words that only run the command after them.
27const COMMAND_WRAPPERS: [&str; 6] = ["sudo", "doas", "time", "nohup", "command", "exec"];
29static CATALOG: Lazy<Catalog> = Lazy::new(|| serde_json::from_str(include_str!(concat!(env!("OUT_DIR"), "/terminal-suggestions.json"))).expect("generated catalog"));
31#[derive(Deserialize)]
32struct Catalog {
33 os: HashMap<String, OsCatalog>,
34 /// Command syntax by family and tool: `[command, risk]`.
35 examples: HashMap<String, HashMap<String, Vec<(String, String)>>>,
38/// The user's own words, like a phone dictionary. Kept in the vault because commands can name servers.
39#[derive(Default, Clone)]
40pub struct Dictionary {
41 pub custom: Vec<String>,
42 pub hidden: HashSet<String>,
45fn clean_words(value: &Value, code: &str) -> CoreResult<Vec<String>> {
46 let mut words: Vec<String> = Vec::new();
47 for word in value.as_array().map(Vec::as_slice).unwrap_or_default() {
48 let word = word.as_str().unwrap_or("").trim();
49 if word.is_empty() || words.iter().any(|known| known == word) {
50 continue;
51 }
52 if word.chars().count() > MAX_INPUT || word.chars().any(char::is_control) || words.len() >= MAX_DICTIONARY {
53 return Err(CoreError::new(code, "A dictionary entry is too long, has control characters, or there are too many entries."));
54 }
55 words.push(word.to_string());
56 }
57 Ok(words)
60#[derive(Deserialize)]
61struct OsCatalog {
62 items: Vec<Item>,
63 commands: Vec<String>,
66#[derive(Deserialize)]
67#[serde(rename_all = "camelCase")]
68struct Item {
69 id: String,
70 title: String,
71 risk: String,
72 privileged: bool,
73 level: String,
74 commands: Vec<String>,
75 keywords: Vec<String>,
78#[derive(Clone, Debug, PartialEq)]
79pub struct Distribution {
80 pub os: Option<String>,
81 pub name: Option<String>,
82 pub version: Option<String>,
85struct Profile {
86 distribution: Distribution,
87 /// Commands found on this server. Empty when unknown, then availability stays unknown.
88 commands: HashSet<String>,
89 home: Option<String>,
90 /// Directory names read for path completion, only in memory for this session.
91 listings: Mutex<HashMap<String, (Instant, Arc<Vec<String>>)>>,
94/// The part of the line before the real command, e.g. `sudo -u www-data `.
95pub fn split_wrapper(input: &str) -> (&str, &str) {
96 let mut rest = input;
97 let mut wrapped = false;
98 loop {
99 let trimmed = rest.trim_start();
100 let word = trimmed.split_whitespace().next().unwrap_or("");
101 if !trimmed.contains(char::is_whitespace) && !word.starts_with('-') {
102 break;
103 }
104 let takes_value = wrapped && matches!(word, "-u" | "-g" | "-h" | "-p" | "--user" | "--group");
105 if COMMAND_WRAPPERS.contains(&word) || (wrapped && word.starts_with('-')) {
106 wrapped = true;
107 let mut after = trimmed[word.len()..].trim_start();
108 if takes_value {
109 let value = after.split_whitespace().next().unwrap_or("");
110 after = after[value.len()..].trim_start();
111 }
112 rest = after;
113 if rest.is_empty() { break; }
114 } else {
115 break;
116 }
117 }
118 let at = input.len() - rest.len();
119 (&input[..at], &input[at..])
122/// Restricted Damerau-Levenshtein distance: a swapped pair counts as one mistake.
123pub fn edit_distance(a: &str, b: &str) -> usize {
124 let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
125 let mut table = vec![vec![0usize; b.len() + 1]; a.len() + 1];
126 for (i, row) in table.iter_mut().enumerate() { row[0] = i; }
127 for j in 0..=b.len() { table[0][j] = j; }
128 for i in 1..=a.len() {
129 for j in 1..=b.len() {
130 let cost = usize::from(a[i - 1] != b[j - 1]);
131 table[i][j] = (table[i - 1][j] + 1).min(table[i][j - 1] + 1).min(table[i - 1][j - 1] + cost);
132 if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
133 table[i][j] = table[i][j].min(table[i - 2][j - 2] + 1);
134 }
135 }
136 }
137 table[a.len()][b.len()]
140fn allowed_mistakes(word: &str) -> usize {
141 match word.chars().count() { 0..=2 => 0, 3..=4 => 1, _ => 2 }
144/// Closest known commands for a mistyped first word.
145fn correct_command(os: Option<&str>, server: &HashSet<String>, word: &str) -> Vec<String> {
146 let limit = allowed_mistakes(word);
147 if limit == 0 || server.contains(word) {
148 return Vec::new();
149 }
150 let catalog = os.and_then(|name| CATALOG.os.get(name));
151 let known = server.iter().map(String::as_str)
152 .chain(catalog.map(|entry| entry.items.iter().flat_map(|item| item.commands.iter().map(|command| first_tool(command)))).into_iter().flatten())
153 .chain(catalog.map(|entry| entry.commands.iter().map(String::as_str)).into_iter().flatten());
154 let lower = word.to_lowercase();
155 let mut scored: Vec<(usize, &str)> = known.filter_map(|name| {
156 let distance = edit_distance(&lower, &name.to_lowercase());
157 (distance <= limit && !name.is_empty()).then_some((distance, name))
158 }).collect();
159 scored.sort_unstable();
160 scored.dedup();
161 if scored.first().is_some_and(|best| best.0 == 0) {
162 return Vec::new();
163 }
164 let best = scored.first().map(|entry| entry.0).unwrap_or(0);
165 scored.into_iter().filter(|entry| entry.0 == best).take(3).map(|entry| entry.1.to_string()).collect()
168/// A path argument at the end of the line: backslashes count as slashes, because this is a POSIX server.
169pub fn path_argument(line: &str, repair_slashes: bool) -> Option<(&str, String)> {
170 let start = line.rfind(char::is_whitespace).map_or(0, |index| index + 1);
171 let token = &line[start..];
172 if start == 0 || token.chars().count() > MAX_INPUT || token.contains(['\'', '"', '$', '`', '*', '?']) {
173 return None;
174 }
175 let before = &line[..start];
176 // A backslash means something else to these programs, usually a regular expression.
177 let (_, rest) = split_wrapper(before);
178 let reads_patterns = matches!(rest.split_whitespace().next().unwrap_or(""), "grep" | "egrep" | "fgrep" | "rg" | "sed" | "awk" | "gawk" | "perl" | "python" | "python3" | "printf" | "echo" | "tr" | "find");
179 if token.contains('\\') && (reads_patterns || !repair_slashes) {
180 return None;
181 }
182 let path = token.replace('\\', "/");
183 (path.starts_with('/') || path.starts_with("~/")).then_some((before, path))
186/// Escapes what the shell would otherwise read differently.
187fn shell_word(path: &str) -> String {
188 let mut out = String::with_capacity(path.len());
189 for c in path.chars() {
190 if !(c.is_alphanumeric() || "_./@%+=:,~-".contains(c)) {
191 out.push('\\');
192 }
193 out.push(c);
194 }
195 out
198/// The entry a finished path component means: exact, other case, or one clear typing mistake.
199fn resolve_component<'a>(entries: &'a [String], wanted: &str) -> Option<&'a str> {
200 let directories = || entries.iter().filter(|entry| entry.ends_with('/')).map(|entry| entry.trim_end_matches('/'));
201 if let Some(found) = directories().find(|entry| *entry == wanted) {
202 return Some(found);
203 }
204 let lower = wanted.to_lowercase();
205 if let Some(found) = directories().find(|entry| entry.to_lowercase() == lower) {
206 return Some(found);
207 }
208 let limit = allowed_mistakes(wanted);
209 let mut scored: Vec<(usize, &str)> = directories().filter_map(|entry| {
210 let distance = edit_distance(&lower, &entry.to_lowercase());
211 (distance <= limit).then_some((distance, entry))
212 }).collect();
213 scored.sort_unstable();
214 match scored.as_slice() {
215 [first, second, ..] if first.0 == second.0 => None,
216 [first, ..] => Some(first.1),
217 [] => None,
218 }
221/// What the command wants as its path argument.
222#[derive(Clone, Copy, Default, PartialEq)]
223pub struct PathContext {
224 pub directories_only: bool,
225 pub files_first: bool,
228pub fn path_context(before: &str) -> PathContext {
229 let (_, rest) = split_wrapper(before);
230 match rest.split_whitespace().next().unwrap_or("") {
231 "cd" | "pushd" | "rmdir" => PathContext { directories_only: true, files_first: false },
232 "nano" | "vi" | "vim" | "nvim" | "emacs" | "sudoedit" | "cat" | "less" | "more" | "tail" | "head" | "bat" | "sha256sum" | "sha512sum" | "md5sum" => PathContext { directories_only: false, files_first: true },
233 _ => PathContext::default(),
234 }
237/// Metadata left by other systems. It stays on the server, it is just not offered unless asked for by name.
238fn is_junk(entry: &str) -> bool {
239 let name = entry.trim_end_matches('/');
240 name == ".DS_Store" || name.starts_with("._") || name == "Thumbs.db" || name == "desktop.ini" || name == ".Spotlight-V100" || name == ".Trashes" || name == ".fseventsd"
243/// Entries for the unfinished last component, most exact first.
244fn complete_component(entries: &[String], wanted: &str, context: PathContext) -> Vec<String> {
245 let lower = wanted.to_lowercase();
246 let limit = allowed_mistakes(wanted);
247 let asked_for_hidden = wanted.starts_with('.');
248 let mut scored: Vec<(usize, u8, &String)> = entries.iter().filter_map(|entry| {
249 if is_junk(entry) && !(asked_for_hidden && entry.starts_with(wanted) && !wanted.is_empty()) { return None; }
250 if context.directories_only && !entry.ends_with('/') { return None; }
251 let name = entry.trim_end_matches('/').to_lowercase();
252 let score = if entry.starts_with(wanted) { 0 } else if name.starts_with(&lower) { 1 } else {
253 let distance = edit_distance(&lower, &name);
254 if wanted.is_empty() || distance > limit { return None; }
255 1 + distance
256 };
257 let hidden = u8::from(entry.starts_with('.') && !asked_for_hidden);
258 let kind = u8::from(context.files_first && entry.ends_with('/'));
259 Some((score, hidden * 2 + kind, entry))
260 }).collect();
261 scored.sort_unstable();
262 scored.into_iter().take(MAX_RESULTS).map(|entry| entry.2.clone()).collect()
265/// Directories a mistyped component could mean, when it is not one clear choice.
266fn close_directories(entries: &[String], wanted: &str) -> Vec<String> {
267 let lower = wanted.to_lowercase();
268 let limit = allowed_mistakes(wanted);
269 let mut scored: Vec<(usize, &String)> = entries.iter().filter(|entry| entry.ends_with('/') && !is_junk(entry)).filter_map(|entry| {
270 let distance = edit_distance(&lower, &entry.trim_end_matches('/').to_lowercase());
271 (distance <= limit).then_some((distance, entry))
272 }).collect();
273 scored.sort_unstable();
274 scored.into_iter().take(MAX_RESULTS).map(|entry| entry.1.clone()).collect()
277/// Typographic and invisible characters that word processors and chat apps put into commands.
278pub fn plain_characters(line: &str) -> String {
279 let mut out = String::with_capacity(line.len());
280 for c in line.chars() {
281 match c {
282 '\u{2013}' | '\u{2014}' | '\u{2015}' | '\u{2212}' => out.push_str("--"),
283 '\u{2010}' | '\u{2011}' => out.push('-'),
284 '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' | '\u{00AB}' | '\u{00BB}' => out.push('"'),
285 '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => out.push('\''),
286 '\u{00A0}' | '\u{2007}' | '\u{202F}' => out.push(' '),
287 '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}' | '\u{00AD}' => {}
288 other => out.push(other),
289 }
290 }
291 out
294#[derive(Default, Debug, PartialEq)]
295pub struct PathMatches {
296 /// The repaired directory part, ending in a slash.
297 pub base: Option<String>,
298 /// Equally likely directories for a mistyped component.
299 pub alternatives: Vec<String>,
300 /// Real entries of the directory.
301 pub entries: Vec<String>,
304/// Rebuilds a path against what `list` finds on the server; returns the ready command lines.
305pub async fn complete_path<F, Fut>(path: &str, home: Option<&str>, context: PathContext, mut list: F) -> PathMatches
306where
307 F: FnMut(String) -> Fut,
308 Fut: std::future::Future<Output = Arc<Vec<String>>>,
310 let (display, mut directory) = if let Some(rest) = path.strip_prefix("~/") {
311 let Some(home) = home.filter(|home| home.starts_with('/')) else { return PathMatches::default() };
312 (("~/".to_string(), rest), format!("{}/", home.trim_end_matches('/')))
313 } else {
314 (("/".to_string(), path.trim_start_matches('/')), "/".to_string())
315 };
316 let (mut shown, rest) = display;
317 let parts: Vec<&str> = rest.split('/').collect();
318 if parts.len() > MAX_PATH_DEPTH {
319 return PathMatches::default();
320 }
321 let (last, finished) = parts.split_last().expect("split yields one part");
322 for part in finished {
323 if part.is_empty() {
324 continue;
325 }
326 if *part == "." || *part == ".." {
327 shown.push_str(part);
328 shown.push('/');
329 directory.push_str(part);
330 directory.push('/');
331 continue;
332 }
333 let entries = list(directory.clone()).await;
334 let Some(found) = resolve_component(&entries, part) else {
335 // Several directories fit equally: show them, the choice stays with the user.
336 let alternatives = close_directories(&entries, part).into_iter().map(|entry| format!("{shown}{entry}")).collect();
337 return PathMatches { base: None, alternatives, entries: Vec::new() };
338 };
339 shown.push_str(found);
340 shown.push('/');
341 directory.push_str(found);
342 directory.push('/');
343 }
344 let entries = list(directory).await;
345 let entries = complete_component(&entries, last, context).into_iter().map(|entry| format!("{shown}{entry}")).collect();
346 PathMatches { base: Some(shown), alternatives: Vec::new(), entries }
349fn field(release: &str, key: &str) -> Option<String> {
350 release.lines().find_map(|line| line.strip_prefix(key)?.strip_prefix('=')).map(|value| value.trim().trim_matches(|c| c == '"' || c == '\'').to_string()).filter(|value| !value.is_empty())
353/// Maps os-release to a matrix. Derivatives use their family; an unknown system gets no catalog, only its own commands.
354pub fn distribution(os_release: &str, openwrt_release: &str) -> Distribution {
355 if !openwrt_release.trim().is_empty() {
356 return Distribution { os: Some("openwrt".into()), name: Some("OpenWrt".into()), version: field(openwrt_release, "DISTRIB_RELEASE") };
357 }
358 let id = field(os_release, "ID").unwrap_or_default().to_lowercase();
359 let like = field(os_release, "ID_LIKE").unwrap_or_default().to_lowercase();
360 let family = |names: &[&str]| names.contains(&id.as_str()) || like.split_whitespace().any(|word| names.contains(&word));
361 let os = match id.as_str() {
362 "ubuntu" | "debian" | "arch" | "rocky" | "alpine" | "openwrt" => Some(id.clone()),
363 _ if family(&["ubuntu"]) => Some("ubuntu".to_string()),
364 _ if family(&["debian"]) => Some("debian".to_string()),
365 _ if family(&["arch"]) => Some("arch".to_string()),
366 _ if family(&["rhel", "fedora", "centos"]) => Some("rocky".to_string()),
367 _ => None,
368 };
369 Distribution { os, name: field(os_release, "NAME"), version: field(os_release, "VERSION_ID") }
372fn first_tool(command: &str) -> &str {
373 let word = command.split_whitespace().next().unwrap_or("");
374 word.rsplit('/').next().unwrap_or(word).trim_matches(|c| c == '\'' || c == '"')
377fn is_cmdlet(command: &str) -> bool {
378 let word = command.split_whitespace().next().unwrap_or("");
379 let mut parts = word.split('-');
380 matches!((parts.next(), parts.next(), parts.next()), (Some(verb), Some(noun), None) if verb.chars().next().is_some_and(char::is_uppercase) && noun.chars().next().is_some_and(char::is_uppercase))
383/// The part that is safe to put on a command line: a `<placeholder>` would be read by the shell as a redirection.
384pub fn insertable(command: &str) -> String {
385 let Some(at) = command.find('<') else { return command.to_string() };
386 let head = &command[..at];
387 let head = head.strip_suffix('\'').or_else(|| head.strip_suffix('"')).unwrap_or(head);
388 let cut = head.rfind(char::is_whitespace).map_or(0, |index| index + 1);
389 let last = &head[cut..];
390 // `dhcp.<section>=host` has no complete word before the placeholder.
391 if last.is_empty() { head.to_string() } else { head[..cut].to_string() }
394fn rank(item: &Item, command: &str, input: &str, lower: &str) -> Option<(u8, bool)> {
395 if command.starts_with(input) {
396 return Some((0, false));
397 }
398 if first_tool(command).starts_with(input) {
399 return Some((1, true));
400 }
401 if lower.len() >= MIN_KEYWORD {
402 if item.keywords.iter().any(|keyword| keyword.to_lowercase().starts_with(lower)) {
403 return Some((2, true));
404 }
405 if item.title.to_lowercase().contains(lower) || item.keywords.iter().any(|keyword| keyword.to_lowercase().contains(lower)) {
406 return Some((3, true));
407 }
408 }
409 None
412fn risk_rank(risk: &str) -> u8 {
413 match risk { "none" => 0, "low" | "unknown" => 1, "medium" => 2, "high" => 3, _ => 4 }
416fn catalog_items(os: Option<&str>, server: &HashSet<String>, input: &str) -> Vec<Value> {
417 let lower = input.to_lowercase();
418 let mut found: Vec<(u8, u8, Value)> = Vec::new();
419 let catalog = os.and_then(|name| CATALOG.os.get(name));
420 for item in catalog.map(|entry| entry.items.as_slice()).unwrap_or_default() {
421 // Prefer the form this server can run, e.g. apk or opkg on OpenWrt.
422 let command = item.commands.iter().find(|command| server.contains(first_tool(command))).or_else(|| item.commands.first());
423 let Some(command) = command else { continue };
424 let Some((score, replaces)) = rank(item, command, input, &lower) else { continue };
425 let insert = insertable(command);
426 if insert.trim().is_empty() || insert.trim() == input.trim() {
427 continue;
428 }
429 let available = if server.is_empty() { None } else { Some(server.contains(first_tool(command))) };
430 let level_rank = match item.level.as_str() { "tested" => 0, "discovered" => 1, _ => 2 };
431 let rank_available = if available == Some(false) { 1 } else { 0 };
432 // Between equal matches the safest comes first, because the first entry is preselected.
433 found.push((score, risk_rank(&item.risk) * 10 + level_rank + rank_available * 5, json!({
434 "id": item.id, "title": item.title, "command": command, "insert": insert, "replacesInput": replaces || !insert.starts_with(input),
435 "risk": item.risk, "level": item.level, "available": available, "privileged": item.privileged, "powershell": is_cmdlet(command)
436 })));
437 }
438 found.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)).then_with(|| a.2["command"].as_str().cmp(&b.2["command"].as_str())));
439 found.dedup_by(|a, b| a.2["command"] == b.2["command"]);
440 found.into_iter().map(|entry| entry.2).collect()
443/// Whole commands for a tool, so the line can grow word by word. Only tools this server has.
444fn syntax_items(os: Option<&str>, server: &HashSet<String>, input: &str) -> Vec<Value> {
445 let Some(os) = os else { return Vec::new() };
446 let windows = os == "windows";
447 let Some(family) = CATALOG.examples.get(if windows { "windows" } else { "linux" }) else { return Vec::new() };
448 let installed = |tool: &str| if windows { CATALOG.os["windows"].commands.iter().any(|name| name.to_lowercase() == tool || name.to_lowercase() == format!("{tool}.exe")) } else { server.contains(tool) };
449 let word = input.split_whitespace().next().unwrap_or("");
450 let finished = input.contains(char::is_whitespace);
451 let key = if windows { word.to_lowercase() } else { word.to_string() };
452 let mut tools: Vec<&String> = if finished {
453 family.get_key_value(&key).map(|entry| vec![entry.0]).unwrap_or_default()
454 } else {
455 family.keys().filter(|tool| tool.starts_with(&key)).collect()
456 };
457 tools.sort_by_key(|tool| (tool.as_str() != key, tool.len(), tool.as_str()));
458 let per_tool = if finished || tools.first().is_some_and(|tool| tool.as_str() == key) { MAX_RESULTS } else { 3 };
459 let compare = |text: &str| if windows { text.to_lowercase() } else { text.to_string() };
460 let wanted = compare(input);
461 let mut found: Vec<(u8, Value)> = Vec::new();
462 for tool in tools.into_iter().filter(|tool| installed(tool)).take(3) {
463 let mut taken = 0;
464 for (command, risk) in &family[tool] {
465 if taken >= per_tool { break; }
466 if !compare(command).starts_with(&wanted) && !(!finished && compare(command).starts_with(tool.as_str())) { continue; }
467 let insert = insertable(command);
468 if insert.trim().is_empty() || compare(insert.trim()) == wanted.trim() { continue; }
469 taken += 1;
470 found.push((risk_rank(risk), json!({ "id": format!("syntax.{command}"), "title": "", "command": command, "insert": insert, "replacesInput": !insert.starts_with(input), "risk": risk, "level": "syntax", "available": true, "privileged": false, "powershell": is_cmdlet(command) })));
471 }
472 }
473 found.sort_by_key(|entry| entry.0);
474 found.into_iter().map(|entry| entry.1).collect()
477fn custom_items(dictionary: &Dictionary, input: &str) -> Vec<Value> {
478 let lower = input.to_lowercase();
479 dictionary.custom.iter().filter(|word| word.to_lowercase().contains(&lower) && word.as_str() != input).map(|word| {
480 let insert = insertable(word);
481 json!({ "id": format!("custom.{word}"), "title": "", "command": word, "insert": insert, "replacesInput": !insert.starts_with(input), "risk": "unknown", "level": "custom", "available": null, "privileged": false, "powershell": false })
482 }).collect()
485fn search_with(os: Option<&str>, server: &HashSet<String>, input: &str, dictionary: &Dictionary) -> Vec<Value> {
486 let visible = |items: Vec<Value>| -> Vec<Value> { items.into_iter().filter(|item| !dictionary.hidden.contains(item["command"].as_str().unwrap_or(""))).collect() };
487 let finished = input.contains(char::is_whitespace);
488 let mut items = visible(custom_items(dictionary, input));
489 let known = visible(catalog_items(os, server, input));
490 let syntax = visible(syntax_items(os, server, input));
491 let catalog_room = if syntax.is_empty() { MAX_RESULTS } else if finished { 4 } else { 3 };
492 items.extend(known.into_iter().take(catalog_room));
493 let syntax_room = if finished { MAX_RESULTS } else { 3 };
494 for item in syntax.into_iter().take(syntax_room) {
495 if items.len() < MAX_RESULTS && !items.iter().any(|known| known["command"] == item["command"]) {
496 items.push(item);
497 }
498 }
499 items.truncate(MAX_RESULTS);
500 // Plain command names complete the rest of the list while the first word is being typed.
501 if !finished && items.len() < MAX_RESULTS {
502 let catalog = os.and_then(|name| CATALOG.os.get(name));
503 let mut names: Vec<&str> = server.iter().map(String::as_str).chain(catalog.map(|entry| entry.commands.iter().map(String::as_str)).into_iter().flatten()).filter(|name| name.starts_with(input) && *name != input).collect();
504 names.sort_unstable();
505 names.dedup();
506 for name in names {
507 if items.len() >= MAX_RESULTS { break; }
508 if items.iter().any(|item| item["command"] == name) || dictionary.hidden.contains(name) { continue; }
509 items.push(json!({ "id": format!("command.{name}"), "title": "", "command": name, "insert": name, "replacesInput": false, "risk": "unknown", "level": "server", "available": true, "privileged": false, "powershell": is_cmdlet(name) }));
510 }
511 }
512 items
515#[cfg(test)]
516fn search(os: Option<&str>, server: &HashSet<String>, input: &str) -> Vec<Value> {
517 search_with(os, server, input, &Dictionary::default())
520pub struct SuggestService {
521 sessions: Arc<SessionManager>,
522 vault: Arc<VaultStore>,
523 profiles: Mutex<HashMap<String, Arc<Profile>>>,
526impl SuggestService {
527 pub fn new(sessions: Arc<SessionManager>, vault: Arc<VaultStore>) -> Self {
528 Self { sessions, vault, profiles: Mutex::new(HashMap::new()) }
529 }
531 pub fn dictionary(&self) -> CoreResult<Value> {
532 let data = self.vault.get_data()?;
533 let stored = &data[DICTIONARY_KEY];
534 Ok(json!({ "custom": stored["custom"].as_array().cloned().unwrap_or_default(), "hidden": stored["hidden"].as_array().cloned().unwrap_or_default() }))
535 }
537 pub fn save_dictionary(&self, payload: &Value) -> CoreResult<Value> {
538 let custom = clean_words(&payload["custom"], "TERMINAL_DICTIONARY_INVALID")?;
539 let hidden = clean_words(&payload["hidden"], "TERMINAL_DICTIONARY_INVALID")?;
540 self.vault.update(|data| {
541 data[DICTIONARY_KEY] = json!({ "custom": custom, "hidden": hidden });
542 Ok(())
543 })?;
544 self.dictionary()
545 }
547 fn loaded_dictionary(&self) -> Dictionary {
548 let Ok(value) = self.dictionary() else { return Dictionary::default() };
549 let words = |key: &str| value[key].as_array().map(|items| items.iter().filter_map(|item| item.as_str().map(str::to_string)).collect::<Vec<_>>()).unwrap_or_default();
550 Dictionary { custom: words("custom"), hidden: words("hidden").into_iter().collect() }
551 }
553 async fn profile(&self, session_id: &str) -> CoreResult<Arc<Profile>> {
554 if let Some(profile) = self.profiles.lock().get(session_id) {
555 return Ok(profile.clone());
556 }
557 let record = self.sessions.require_connected(session_id)?;
558 let platform = detect_platform(&self.sessions, &record, false).await?;
559 let profile = if platform["os"] == "windows" {
560 let version = platform["caption"].as_str().or_else(|| platform["name"].as_str()).map(str::to_string);
561 Profile { distribution: Distribution { os: Some("windows".into()), name: if version.is_some() { None } else { Some("Windows".into()) }, version }, commands: HashSet::new(), home: None, listings: Mutex::new(HashMap::new()) }
562 } else {
563 let output = self.sessions.exec_operation(&record, "terminal.suggestions.read", &[], Duration::from_secs(8), crate::core::ssh::exec::MAX_COMMAND_OUTPUT).await?.stdout;
564 let rest = output.split_once("@@HOME").map_or(output.as_str(), |(_, rest)| rest);
565 let (home, commands) = rest.split_once("@@COMMANDS").unwrap_or((rest, ""));
566 let home = Some(home.trim().to_string()).filter(|home| home.starts_with('/'));
567 let commands = commands.lines().map(str::trim).filter(|name| !name.is_empty() && !name.ends_with(':') && !name.contains('/')).take(MAX_SERVER_COMMANDS).map(str::to_string).collect();
568 let text = |key: &str| platform[key].as_str().map(str::to_string);
569 Profile { distribution: Distribution { os: text("matrixOs"), name: text("distributionName"), version: text("distributionVersion") }, commands, home, listings: Mutex::new(HashMap::new()) }
570 };
571 let profile = Arc::new(profile);
572 let live: HashSet<String> = self.sessions.list().iter().filter_map(|status| status["sessionId"].as_str().map(str::to_string)).collect();
573 let mut profiles = self.profiles.lock();
574 profiles.retain(|id, _| live.contains(id));
575 profiles.insert(session_id.to_string(), profile.clone());
576 Ok(profile)
577 }
579 /// Directory names under the SSH account, without sudo. An unreadable directory simply has no entries.
580 async fn list_directory(&self, session_id: &str, profile: &Profile, directory: String) -> Arc<Vec<String>> {
581 if let Some((read, entries)) = profile.listings.lock().get(&directory) {
582 if read.elapsed() < LIST_TTL {
583 return entries.clone();
584 }
585 }
586 let command = format!("LC_ALL=C; ls -1Ap -- {} 2>/dev/null; exit 0", shell_quote(&directory));
587 let Ok(record) = self.sessions.require_connected(session_id) else { return Arc::new(Vec::new()) };
588 let entries: Vec<String> = match self.sessions.exec_fixed_default(&record, &command, Duration::from_secs(5)).await {
589 Ok(result) => result.stdout.lines().filter(|line| !line.is_empty()).take(MAX_DIRECTORY_ENTRIES).map(str::to_string).collect(),
590 Err(_) => Vec::new(),
591 };
592 let entries = Arc::new(entries);
593 let mut listings = profile.listings.lock();
594 if listings.len() >= MAX_LISTED_DIRECTORIES {
595 listings.clear();
596 }
597 listings.insert(directory, (Instant::now(), entries.clone()));
598 entries
599 }
601 /// `input` is the text the terminal displayed. Control characters mean it is not a plain command line.
602 pub async fn suggest(&self, session_id: &str, input: &str, repair_paths: bool) -> CoreResult<Value> {
603 self.sessions.require_connected(session_id).map_err(|_| CoreError::new("SESSION_NOT_CONNECTED", "The SSH session is not connected."))?;
604 let profile = self.profile(session_id).await?;
605 let distribution = &profile.distribution;
606 let typed = input.trim_start();
607 let valid = typed.chars().count() >= 2 && typed.chars().count() <= MAX_INPUT && !typed.chars().any(char::is_control);
608 let mut items = Vec::new();
609 if valid {
610 let plain = plain_characters(typed);
611 let line = plain.as_str();
612 let windows = distribution.os.as_deref() == Some("windows");
613 if !windows {
614 if let Some((before, path)) = path_argument(line, repair_paths) {
615 let matches = complete_path(&path, profile.home.as_deref(), path_context(before), |directory| self.list_directory(session_id, &profile, directory)).await;
616 items = path_items(before, &path, typed, matches);
617 }
618 }
619 if items.is_empty() {
620 items = command_items(distribution.os.as_deref(), &profile.commands, line, &self.loaded_dictionary());
621 }
622 if line != typed {
623 // The typed line itself had characters a shell does not read as meant; offer the plain form first.
624 for item in &mut items { item["replacesInput"] = json!(true); }
625 items.insert(0, json!({ "id": "corrected.characters", "title": "", "command": line, "insert": line, "replacesInput": true, "risk": "unknown", "level": "corrected", "available": null, "privileged": false, "powershell": false }));
626 items.truncate(MAX_RESULTS);
627 }
628 }
629 Ok(json!({ "os": distribution.os, "name": distribution.name, "version": distribution.version, "items": items }))
630 }
633fn path_items(before: &str, path: &str, line: &str, matches: PathMatches) -> Vec<Value> {
634 let item = |candidate: &str, level: &str| {
635 let insert = format!("{before}{}", shell_word(candidate));
636 json!({ "id": format!("path.{candidate}"), "title": "", "command": insert, "insert": insert, "replacesInput": !insert.starts_with(line), "risk": "unknown", "level": level, "available": true, "privileged": false, "powershell": false, "continues": candidate.ends_with('/') })
637 };
638 let mut items = Vec::new();
639 for candidate in &matches.alternatives {
640 items.push(item(candidate, "corrected"));
641 }
642 // Repairing a directory is its own suggestion. An unfinished name is not: a real entry is better than a repaired guess.
643 let typed_path = line.strip_prefix(before).unwrap_or(line);
644 if let Some(base) = matches.base.as_ref().filter(|base| path.ends_with('/') && base.as_str() != typed_path) {
645 items.push(item(base, "corrected"));
646 }
647 let exact_directory = path.ends_with('/');
648 for candidate in &matches.entries {
649 if candidate != path || exact_directory {
650 items.push(item(candidate, if candidate.ends_with('/') { "directory" } else { "file" }));
651 }
652 }
653 items.truncate(MAX_RESULTS);
654 items
657/// Commands for the line, also behind `sudo` and with a corrected first word.
658fn command_items(os: Option<&str>, server: &HashSet<String>, line: &str, dictionary: &Dictionary) -> Vec<Value> {
659 let (wrapper, rest) = split_wrapper(line);
660 if rest.chars().count() < 2 {
661 return Vec::new();
662 }
663 let mut items = search_with(os, server, rest, dictionary);
664 if items.is_empty() {
665 let word = rest.split_whitespace().next().unwrap_or("");
666 let tail = &rest[word.len()..];
667 for name in correct_command(os, server, word) {
668 let fixed = format!("{name}{tail}");
669 items.push(json!({ "id": format!("corrected.{name}"), "title": "", "command": fixed, "insert": fixed, "replacesInput": true, "risk": "unknown", "level": "corrected", "available": if server.is_empty() { Value::Null } else { Value::Bool(server.contains(&name)) }, "privileged": false, "powershell": is_cmdlet(&name) }));
670 items.extend(search_with(os, server, &fixed, dictionary).into_iter().filter(|item| item["command"] != fixed.as_str()));
671 }
672 items.truncate(MAX_RESULTS);
673 }
674 for item in &mut items {
675 let command = format!("{wrapper}{}", item["command"].as_str().unwrap_or(""));
676 let insert = format!("{wrapper}{}", item["insert"].as_str().unwrap_or(""));
677 item["replacesInput"] = json!(!insert.starts_with(line));
678 item["command"] = json!(command);
679 item["insert"] = json!(insert);
680 }
681 items
684#[cfg(test)]
685mod tests {
686 use super::*;
688 fn server(names: &[&str]) -> HashSet<String> {
689 names.iter().map(|name| name.to_string()).collect()
690 }
692 #[test]
693 fn recognises_each_distribution() {
694 let os = |text: &str| distribution(text, "").os;
695 assert_eq!(os("ID=ubuntu\nVERSION_ID=\"24.04\"\nID_LIKE=debian"), Some("ubuntu".into()));
696 assert_eq!(os("ID=debian\nVERSION_ID=\"13\""), Some("debian".into()));
697 assert_eq!(os("ID=arch\n"), Some("arch".into()));
698 assert_eq!(os("ID=\"rocky\"\nID_LIKE=\"rhel centos fedora\""), Some("rocky".into()));
699 assert_eq!(os("ID=alpine\n"), Some("alpine".into()));
700 assert_eq!(os("ID=kali\nID_LIKE=debian"), Some("debian".into()));
701 assert_eq!(os("ID=almalinux\nID_LIKE=\"rhel centos fedora\""), Some("rocky".into()));
702 assert_eq!(os("ID=freebsd"), None);
703 let openwrt = distribution("ID=\"openwrt\"", "DISTRIB_ID='OpenWrt'\nDISTRIB_RELEASE='25.12.6'\n");
704 assert_eq!((openwrt.os.as_deref(), openwrt.version.as_deref()), (Some("openwrt"), Some("25.12.6")));
705 }
707 #[test]
708 fn never_inserts_a_placeholder() {
709 assert_eq!(insertable("uci show '<config>'"), "uci show ");
710 assert_eq!(insertable("chage -l <user>"), "chage -l ");
711 assert_eq!(insertable("nft list ruleset"), "nft list ruleset");
712 assert_eq!(insertable("uci set 'dhcp.<section>=host'"), "uci set ");
713 assert_eq!(insertable("sysupgrade '<image>'"), "sysupgrade ");
714 for (name, catalog) in &CATALOG.os {
715 for item in &catalog.items {
716 for command in &item.commands {
717 assert!(!insertable(command).contains('<'), "{name}: {command}");
718 }
719 }
720 }
721 }
723 #[test]
724 fn every_catalog_os_exists_and_matches_its_distribution() {
725 for name in ["ubuntu", "debian", "arch", "rocky", "alpine", "windows", "openwrt"] {
726 assert!(!CATALOG.os[name].items.is_empty(), "{name}");
727 }
728 let uci = search(Some("openwrt"), &server(&["uci"]), "uci s");
729 assert!(uci.iter().any(|item| item["command"].as_str().unwrap().starts_with("uci show")), "{uci:?}");
730 assert!(search(Some("ubuntu"), &server(&["ls"]), "uci s").is_empty(), "uci is an OpenWrt command");
731 }
733 #[test]
734 fn picks_the_form_the_server_can_run() {
735 let apk = search(Some("openwrt"), &server(&["apk"]), "apk up");
736 let opkg = search(Some("openwrt"), &server(&["opkg"]), "opkg up");
737 assert!(apk.iter().any(|item| item["available"] == true));
738 assert!(opkg.iter().any(|item| item["available"] == true));
739 }
741 #[test]
742 fn marks_missing_tools_and_lists_server_commands_and_critical_risk() {
743 let reset = search(Some("openwrt"), &server(&["uci"]), "defaults");
744 assert!(reset.iter().any(|item| item["risk"] == "critical" && item["available"] == false), "{reset:?}");
745 let names = search(None, &server(&["myscript", "mytool"]), "my");
746 assert_eq!(names.iter().map(|item| item["command"].as_str().unwrap()).collect::<Vec<_>>(), ["myscript", "mytool"]);
747 assert!(search(None, &server(&[]), "ls").is_empty());
748 }
750 fn listing(entries: &[&str]) -> Arc<Vec<String>> {
751 Arc::new(entries.iter().map(|entry| entry.to_string()).collect())
752 }
754 fn fake_server(directory: String) -> std::future::Ready<Arc<Vec<String>>> {
755 std::future::ready(match directory.as_str() {
756 "/" => listing(&["etc/", "home/", "var/"]),
757 "/etc/" => listing(&["hostname", "hosts", "nginx/", "ssh/"]),
758 "/etc/nginx/" => listing(&["nginx.conf", "sites-available/", "sites-enabled/"]),
759 "/etc/nginx/sites-enabled/" => listing(&["default", "my site.conf"]),
760 "/home/ai/" => listing(&[".ssh/", "notes.txt"]),
761 _ => listing(&[]),
762 })
763 }
765 #[test]
766 fn wrappers_are_kept_and_skipped() {
767 assert_eq!(split_wrapper("sudo hostname"), ("sudo ", "hostname"));
768 assert_eq!(split_wrapper("sudo -u www-data nginx -t"), ("sudo -u www-data ", "nginx -t"));
769 assert_eq!(split_wrapper("sudo -E time ls"), ("sudo -E time ", "ls"));
770 assert_eq!(split_wrapper("hostname"), ("", "hostname"));
771 assert_eq!(split_wrapper("sudo"), ("", "sudo"));
772 }
774 #[test]
775 fn commands_work_behind_sudo() {
776 let names = command_items(Some("ubuntu"), &server(&["hostname", "hostnamectl"]), "sudo hostn", &Dictionary::default());
777 assert_eq!(names[0]["insert"], "sudo hostname");
778 assert_eq!(names[0]["replacesInput"], false);
779 let test = command_items(Some("ubuntu"), &server(&["nginx"]), "sudo nginx -", &Dictionary::default());
780 assert!(test.iter().any(|item| item["insert"] == "sudo nginx -t"), "{test:?}");
781 }
783 #[test]
784 fn corrects_a_mistyped_command() {
785 assert_eq!(edit_distance("nxinx", "nginx"), 1);
786 assert_eq!(edit_distance("ngnix", "nginx"), 1);
787 let fixed = command_items(Some("ubuntu"), &server(&["nginx", "ls"]), "sudo nxinx -t", &Dictionary::default());
788 assert_eq!(fixed[0]["insert"], "sudo nginx -t");
789 assert_eq!(fixed[0]["level"], "corrected");
790 assert_eq!(fixed[0]["replacesInput"], true);
791 assert!(command_items(Some("ubuntu"), &server(&["ab"]), "qqqqqqqq", &Dictionary::default()).is_empty());
792 }
794 #[tokio::test]
795 async fn repairs_slashes_and_typing_mistakes_in_paths() {
796 let (before, path) = path_argument("sudo nano \\etc\\nginx\\site-enable\\", true).unwrap();
797 assert_eq!((before, path.as_str()), ("sudo nano ", "/etc/nginx/site-enable/"));
798 let found = |path: &'static str, home: Option<&'static str>| async move { complete_path(path, home, PathContext::default(), fake_server).await.entries };
799 assert_eq!(complete_path(&path, None, PathContext::default(), fake_server).await.entries, ["/etc/nginx/sites-enabled/default", "/etc/nginx/sites-enabled/my site.conf"]);
800 assert_eq!(found("/ETC/ngnix/site", None).await, ["/etc/nginx/sites-available/", "/etc/nginx/sites-enabled/"]);
801 assert_eq!(found("/etc/host", None).await, ["/etc/hostname", "/etc/hosts"]);
802 assert_eq!(found("~/no", Some("/home/ai")).await, ["~/notes.txt"]);
803 assert_eq!(found("/etc//nginx///nginx", None).await, ["/etc/nginx/nginx.conf"]);
804 assert!(found("/etc/zzzzzz/x", None).await.is_empty());
805 }
807 #[test]
808 fn path_items_separate_the_repair_from_what_exists() {
809 let matches = PathMatches { base: Some("/etc/nginx/".into()), alternatives: vec![], entries: vec!["/etc/nginx/nginx.conf".into(), "/etc/nginx/conf.d/".into(), "/etc/nginx/my site.conf".into()] };
810 let items = path_items("sudo nano ", "/etc/nginx/", "sudo nano \\\\etc\\\\nginx\\\\", matches);
811 let shown: Vec<(&str, &str)> = items.iter().map(|item| (item["insert"].as_str().unwrap(), item["level"].as_str().unwrap())).collect();
812 assert_eq!(shown[0], ("sudo nano /etc/nginx/", "corrected"));
813 assert_eq!(shown[1], ("sudo nano /etc/nginx/nginx.conf", "file"));
814 assert_eq!(shown[2], ("sudo nano /etc/nginx/conf.d/", "directory"));
815 assert_eq!(shown[3], ("sudo nano /etc/nginx/my\\ site.conf", "file"));
816 assert_eq!(items[0]["continues"], true);
817 assert_eq!(items[1]["replacesInput"], true);
818 let plain = PathMatches { base: Some("/etc/".into()), alternatives: vec![], entries: vec!["/etc/hosts".into()] };
819 let items = path_items("cat ", "/etc/ho", "cat /etc/ho", plain);
820 assert_eq!((items.len(), items[0]["level"].as_str().unwrap(), items[0]["replacesInput"].as_bool().unwrap()), (1, "file", false), "nothing to repair");
821 for bad in ["echo $(reboot)/x", "cat `id`/x", "ls '/etc", "ls /etc/*"] {
822 assert!(path_argument(bad, true).is_none(), "{bad}");
823 }
824 assert!(path_argument("/etc/hosts", true).is_none(), "the command word is not a path argument");
825 assert!(path_argument("grep \\\\d+", true).is_none(), "a regular expression stays as typed");
826 assert!(path_argument("sudo sed \\\\bfoo", true).is_none());
827 assert!(path_argument("nano \\\\etc", false).is_none(), "repair can be turned off");
828 assert!(path_argument("nano /etc", false).is_some(), "completion still works");
829 }
831 #[test]
832 fn junk_is_not_offered_and_commands_shape_the_list() {
833 let entries = listing(&[".DS_Store", "._.DS_Store", ".hidden", "conf.d/", "nginx.conf", "sites/"]);
834 assert_eq!(complete_component(&entries, "", PathContext::default()), ["conf.d/", "nginx.conf", "sites/", ".hidden"]);
835 assert_eq!(complete_component(&entries, ".D", PathContext::default()), [".DS_Store"]);
836 assert_eq!(complete_component(&entries, "", path_context("cd ")), ["conf.d/", "sites/"]);
837 assert_eq!(complete_component(&entries, "", path_context("sudo nano ")), ["nginx.conf", "conf.d/", "sites/", ".hidden"]);
838 }
840 #[tokio::test]
841 async fn equal_candidates_stay_a_choice() {
842 fn two(_: String) -> std::future::Ready<Arc<Vec<String>>> { std::future::ready(listing(&["site-a/", "site-b/"])) }
843 let matches = complete_path("/site-c/x", None, PathContext::default(), two).await;
844 assert_eq!(matches.alternatives, ["/site-a/", "/site-b/"]);
845 assert!(path_items("cat ", "/site-c/x", "cat /site-c/x", matches).iter().all(|item| item["level"] == "corrected" && item["continues"] == true));
846 }
848 #[test]
849 fn typographic_characters_become_plain() {
850 assert_eq!(plain_characters("ls \u{2013}help"), "ls --help");
851 assert_eq!(plain_characters("echo \u{201C}text\u{201D}"), "echo \"text\"");
852 assert_eq!(plain_characters("ls\u{00A0}-l\u{200B}"), "ls -l");
853 assert_eq!(plain_characters("ls -l"), "ls -l");
854 }
856 #[test]
857 fn critical_commands_are_reachable() {
858 let found = search(Some("openwrt"), &server(&["sysupgrade", "uci"]), "sysupgrade");
859 let commands: Vec<&str> = found.iter().map(|item| item["command"].as_str().unwrap()).collect();
860 assert!(found.iter().any(|item| item["risk"] == "critical"), "{commands:?}");
861 }
863 #[test]
864 fn whole_commands_grow_word_by_word() {
865 let tool = server(&["systemctl"]);
866 let first = search(Some("ubuntu"), &tool, "systemc");
867 assert!(first.iter().any(|item| item["command"] == "systemctl status"), "{first:?}");
868 let next = search(Some("ubuntu"), &tool, "systemctl re");
869 let inserts: Vec<&str> = next.iter().map(|item| item["insert"].as_str().unwrap()).collect();
870 assert!(inserts.contains(&"systemctl restart ") && inserts.contains(&"systemctl reload "), "{inserts:?}");
871 assert!(inserts.iter().all(|insert| !insert.contains('<')), "{inserts:?}");
872 assert_eq!(next.iter().find(|item| item["insert"] == "systemctl restart ").unwrap()["risk"], "high");
873 assert!(search(Some("ubuntu"), &server(&["ls"]), "systemctl re").iter().all(|item| item["available"] == false), "a tool the server lacks is marked");
874 }
876 #[test]
877 fn dangerous_syntax_is_marked() {
878 let found = search(Some("ubuntu"), &server(&["mkfs", "rm"]), "mkfs ");
879 assert!(!found.is_empty() && found.iter().all(|item| item["risk"] == "critical"), "{found:?}");
880 let rm = search(Some("ubuntu"), &server(&["rm"]), "rm -");
881 assert!(rm.iter().any(|item| item["risk"] == "critical" || item["risk"] == "high"), "{rm:?}");
882 }
884 #[test]
885 fn dictionary_adds_and_hides() {
886 let mut words = Dictionary { custom: vec!["deploy-web --prod".into()], hidden: HashSet::new() };
887 let own = search_with(Some("ubuntu"), &server(&[]), "deploy", &words);
888 assert_eq!((own[0]["insert"].as_str().unwrap(), own[0]["level"].as_str().unwrap()), ("deploy-web --prod", "custom"));
889 let tool = server(&["hostname"]);
890 assert!(search_with(Some("ubuntu"), &tool, "hostname ", &words).iter().any(|item| item["command"] == "hostname -f"));
891 words.hidden.insert("hostname -f".into());
892 assert!(!search_with(Some("ubuntu"), &tool, "hostname ", &words).iter().any(|item| item["command"] == "hostname -f"));
893 assert!(clean_words(&json!(["ok", "bad\u{7}"]), "X").is_err());
894 assert_eq!(clean_words(&json!([" a ", "a", ""]), "X").unwrap(), ["a"]);
895 }
897 #[test]
898 fn safest_equal_match_comes_first() {
899 let found = search(Some("openwrt"), &server(&["uci"]), "uci s");
900 assert_ne!(found[0]["risk"], "high", "{found:?}");
901 let risks: Vec<&str> = found.iter().map(|item| item["risk"].as_str().unwrap()).collect();
902 let high = risks.iter().position(|risk| *risk == "high").unwrap_or(risks.len());
903 assert!(risks[..high].iter().all(|risk| ["none", "low", "unknown", "medium"].contains(risk)), "{risks:?}");
904 }
906 #[test]
907 fn long_commands_are_held_back_only_on_dropbear() {
908 use crate::core::ssh::session::command_too_long_for;
909 let long = "x".repeat(39_000);
910 assert!(command_too_long_for("SSH-2.0-dropbear_2025.88", &long));
911 assert!(!command_too_long_for("SSH-2.0-dropbear_2025.88", crate::core::matrix::script("suggest_probe.sh")), "the probe must fit");
912 assert!(!command_too_long_for("SSH-2.0-OpenSSH_9.6", &long));
913 assert!(!command_too_long_for("", &long));
914 }
916 #[test]
917 fn typed_keywords_replace_the_input() {
918 let found = search(Some("ubuntu"), &server(&["ip"]), "route");
919 assert!(found.iter().any(|item| item["replacesInput"] == true), "{found:?}");
920 }
SHA-256e2007066db6ba2aea7f9e65715a57fef8f8cc5604d15f387da8572ea2a59687c

CH-J Proprietary Software License 1.14 · سورس CH-J Proprietary Software License 1.14 کے تحت فراہم کیا جاتا ہے۔ اس کی دستیابی لائسنس کی شرائط نہیں بدلتی اور نہ ہی اضافی حقوق دیتی ہے۔

آرکائیو کا SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
فائلیں