Objavljen izvor
Preuzmi ZIP izvora
CH-J Tools
Pregledaj direktorije i datoteke za određeni programski izdanje.
CH-J / Server Manager
1704 datoteke
0.1.2-beta
Dodano: · Izgrađeno:
1
//! Terminal autocomplete: recognises the distribution of the connected server and offers commands from the2
//! catalog compiled from `Matrix json` as text. Nothing here runs a suggestion; the typed text is never logged or stored.4
use crate::core::error::{CoreError, CoreResult};5
use crate::core::ssh::platform::detect_platform;6
use crate::core::ssh::session::{shell_quote, SessionManager};7
use crate::core::vault::VaultStore;8
use once_cell::sync::Lazy;9
use parking_lot::Mutex;10
use serde::Deserialize;11
use serde_json::{json, Value};12
use std::collections::{HashMap, HashSet};13
use std::sync::Arc;14
use std::time::{Duration, Instant};16
const MAX_INPUT: usize = 200;17
const MAX_RESULTS: usize = 8;18
const MIN_KEYWORD: usize = 3;19
const MAX_SERVER_COMMANDS: usize = 20_000;20
const LIST_TTL: Duration = Duration::from_secs(15);21
const MAX_LISTED_DIRECTORIES: usize = 128;22
const MAX_DIRECTORY_ENTRIES: usize = 5_000;23
const MAX_PATH_DEPTH: usize = 24;24
const MAX_DICTIONARY: usize = 500;25
const DICTIONARY_KEY: &str = "terminalDictionary";26
/// Words that only run the command after them.27
const COMMAND_WRAPPERS: [&str; 6] = ["sudo", "doas", "time", "nohup", "command", "exec"];29
static CATALOG: Lazy<Catalog> = Lazy::new(|| serde_json::from_str(include_str!(concat!(env!("OUT_DIR"), "/terminal-suggestions.json"))).expect("generated catalog"));31
#[derive(Deserialize)]32
struct Catalog {33
os: HashMap<String, OsCatalog>,34
/// Command syntax by family and tool: `[command, risk]`.35
examples: HashMap<String, HashMap<String, Vec<(String, String)>>>,36
}38
/// The user's own words, like a phone dictionary. Kept in the vault because commands can name servers.39
#[derive(Default, Clone)]40
pub struct Dictionary {41
pub custom: Vec<String>,42
pub hidden: HashSet<String>,43
}45
fn 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)58
}60
#[derive(Deserialize)]61
struct OsCatalog {62
items: Vec<Item>,63
commands: Vec<String>,64
}66
#[derive(Deserialize)]67
#[serde(rename_all = "camelCase")]68
struct Item {69
id: String,70
title: String,71
risk: String,72
privileged: bool,73
level: String,74
commands: Vec<String>,75
keywords: Vec<String>,76
}78
#[derive(Clone, Debug, PartialEq)]79
pub struct Distribution {80
pub os: Option<String>,81
pub name: Option<String>,82
pub version: Option<String>,83
}85
struct 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>>)>>,92
}94
/// The part of the line before the real command, e.g. `sudo -u www-data `.95
pub 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..])120
}122
/// Restricted Damerau-Levenshtein distance: a swapped pair counts as one mistake.123
pub 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()]138
}140
fn allowed_mistakes(word: &str) -> usize {141
match word.chars().count() { 0..=2 => 0, 3..=4 => 1, _ => 2 }142
}144
/// Closest known commands for a mistyped first word.145
fn 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()166
}168
/// A path argument at the end of the line: backslashes count as slashes, because this is a POSIX server.169
pub 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))184
}186
/// Escapes what the shell would otherwise read differently.187
fn 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
out196
}198
/// The entry a finished path component means: exact, other case, or one clear typing mistake.199
fn 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
}219
}221
/// What the command wants as its path argument.222
#[derive(Clone, Copy, Default, PartialEq)]223
pub struct PathContext {224
pub directories_only: bool,225
pub files_first: bool,226
}228
pub 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
}235
}237
/// Metadata left by other systems. It stays on the server, it is just not offered unless asked for by name.238
fn 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"241
}243
/// Entries for the unfinished last component, most exact first.244
fn 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 + distance256
};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()263
}265
/// Directories a mistyped component could mean, when it is not one clear choice.266
fn 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()275
}277
/// Typographic and invisible characters that word processors and chat apps put into commands.278
pub 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
out292
}294
#[derive(Default, Debug, PartialEq)]295
pub 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>,302
}304
/// Rebuilds a path against what `list` finds on the server; returns the ready command lines.305
pub async fn complete_path<F, Fut>(path: &str, home: Option<&str>, context: PathContext, mut list: F) -> PathMatches306
where307
F: FnMut(String) -> Fut,308
Fut: std::future::Future<Output = Arc<Vec<String>>>,309
{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 }347
}349
fn 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())351
}353
/// Maps os-release to a matrix. Derivatives use their family; an unknown system gets no catalog, only its own commands.354
pub 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") }370
}372
fn 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 == '"')375
}377
fn 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))381
}383
/// The part that is safe to put on a command line: a `<placeholder>` would be read by the shell as a redirection.384
pub 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() }392
}394
fn 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
None410
}412
fn risk_rank(risk: &str) -> u8 {413
match risk { "none" => 0, "low" | "unknown" => 1, "medium" => 2, "high" => 3, _ => 4 }414
}416
fn 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()441
}443
/// Whole commands for a tool, so the line can grow word by word. Only tools this server has.444
fn 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()475
}477
fn 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()483
}485
fn 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
items513
}515
#[cfg(test)]516
fn search(os: Option<&str>, server: &HashSet<String>, input: &str) -> Vec<Value> {517
search_with(os, server, input, &Dictionary::default())518
}520
pub struct SuggestService {521
sessions: Arc<SessionManager>,522
vault: Arc<VaultStore>,523
profiles: Mutex<HashMap<String, Arc<Profile>>>,524
}526
impl 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
entries599
}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
}631
}633
fn 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
items655
}657
/// Commands for the line, also behind `sudo` and with a corrected first word.658
fn 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
items682
}684
#[cfg(test)]685
mod 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
}921
}SHA-256
e2007066db6ba2aea7f9e65715a57fef8f8cc5604d15f387da8572ea2a59687cCH-J Proprietary Software License 1.14 · Izvor je osiguran pod CH-J Proprietary Software License 1.14. Njena dostupnost ne mijenja uvjete licenciranja niti daje dodatna prava.
Arhiv SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f