CH-J Toolsmanagementul serverului peste SSH
Meniu
Sursă publicată

CH-J Tools

Navighează directoarele și fișierele pentru o anumită versiune a aplicației.

Descarcă sursa ZIP
CH-J / Server Manager 872 fișiere
core-20261030T220507Z-61e4558d889f Adăugat: · Construit:
virtual_memory.rs Adăugat: Data neînregistrată 33,7 KB · 603 linii
Cod Descarcă fișierul
1//! Virtual memory for the System Monitor module (port of `system/virtualMemoryService.js`):
2//! Linux swap (files, partitions, zram, zswap) and Windows pagefiles. Reads never change
3//! anything; changes go only through fixed operations with validated arguments.
5use crate::core::error::{CoreError, CoreResult};
6use crate::core::logging::Logger;
7use crate::core::ssh::platform::detect_platform;
8use crate::core::ssh::privileged::{PrivilegedExecutor, RunOptions};
9use crate::core::ssh::session::SessionManager;
10use crate::core::ssh::windows_exec::run_windows_script;
11use crate::core::users::common::{clean_text, js_number_value, js_string, key_values, lines, now_iso, split_sections};
12use once_cell::sync::Lazy;
13use parking_lot::Mutex;
14use regex::Regex;
15use serde_json::{json, Map, Value};
16use std::collections::HashSet;
17use std::sync::Arc;
18use std::time::Duration;
20const SWAP_SCRIPT: &str = include_str!("../../../resources/remote/remoteSwap.sh");
21const PAGEFILE_SCRIPT: &str = include_str!("../../../resources/remote/windowsPagefile.ps1");
22static MANAGED_PATH: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/(swapfile[A-Za-z0-9._-]{0,32}|swap/[A-Za-z0-9._-]{1,64}|var/lib/swap/[A-Za-z0-9._-]{1,64})$").unwrap());
23static ANY_SWAP_PATH: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/-]{1,199}$").unwrap());
24static ZRAM: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/dev/zram\d+$").unwrap());
25static PRI: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?:^|,)pri=(-?\d+)").unwrap());
26static STEP: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_STEP=([a-z-]+)\|([a-z]+)\|([^\n]*)\n").unwrap());
27const LINUX_ACTIONS: [&str; 7] = ["create", "activate", "deactivate", "priority", "persist", "resize", "delete"];
28const WINDOWS_ACTIONS: [&str; 3] = ["automatic", "set", "remove"];
30fn fail(code: &str, message: &str) -> CoreError {
31 CoreError::new(code, message)
34/// 0.1.0 `number()`: empty/null/non-finite → null.
35fn number(text: &str) -> Value {
36 if text.is_empty() {
37 return Value::Null;
38 }
39 match js_number_value(&json!(text)) {
40 Some(n) if n.is_finite() => json!(n),
41 _ => Value::Null,
42 }
45fn num_value(value: &Value) -> Value {
46 match value {
47 Value::Null => Value::Null,
48 Value::String(s) if s.is_empty() => Value::Null,
49 other => match js_number_value(other) {
50 Some(n) if n.is_finite() => json!(n),
51 _ => Value::Null,
52 },
53 }
56fn js_num(text: &str) -> f64 {
57 js_number_value(&json!(text)).unwrap_or(f64::NAN)
60fn json_num(n: f64) -> Value {
61 if n.is_finite() { json!(n) } else { Value::Null }
64pub fn parse_proc_swaps(text: &str) -> Vec<Value> {
65 lines(text)
66 .into_iter()
67 .skip(1)
68 .map(|line| {
69 let fields: Vec<&str> = line.split_whitespace().collect();
70 let field = |i: usize| fields.get(i).copied().unwrap_or("");
71 let path = field(0).replace("\\040", " ");
72 let kind = field(1);
73 json!({
74 "path": clean_text(&path, 200),
75 "type": if ZRAM.is_match(&path) { "zram".to_string() } else if kind == "file" { "file".into() } else if kind == "partition" { "partition".into() } else { clean_text(kind, 16) },
76 "size": json_num(js_num(field(2)) * 1024.0),
77 "used": json_num(js_num(field(3)) * 1024.0),
78 "priority": json_num(js_num(field(4)))
79 })
80 })
81 .collect()
84fn unit_name(path: &str) -> String {
85 format!("{}.swap", path.trim_start_matches('/').replace('-', "\\x2d").replace('/', "-"))
88pub fn parse_linux_inspect(stdout: &str) -> Value {
89 let sections = split_sections(stdout);
90 let section = |name: &str| sections.get(name).map(String::as_str).unwrap_or("");
91 let meta = key_values(section("meta"));
92 let mut meminfo = Map::new();
93 for line in lines(section("meminfo")) {
94 let mut parts = line.splitn(2, ':');
95 let key = parts.next().unwrap_or("").to_string();
96 let value = parts.next().unwrap_or("").trim();
97 let digits: String = value.chars().take_while(|c| c.is_ascii_digit() || *c == '-').collect();
98 meminfo.insert(key, digits.parse::<f64>().map(|n| json!(n * 1024.0)).unwrap_or(Value::Null));
99 }
100 let active = parse_proc_swaps(section("swaps"));
101 let fstab: Vec<Value> = lines(section("fstab"))
102 .into_iter()
103 .filter_map(|line| {
104 // line|canonical identity|raw line (older helpers sent line|raw).
105 let index = line.find('|')?;
106 let second = line[index + 1..].find('|').map(|i| i + index + 1);
107 let with_canonical = second.is_some_and(|s| !line[index + 1..s].chars().any(char::is_whitespace));
108 let (raw, resolved) = match (with_canonical, second) {
109 (true, Some(s)) => (&line[s + 1..], json!(clean_text(&line[index + 1..s], 200))),
110 _ => (&line[index + 1..], Value::Null),
111 };
112 let fields: Vec<&str> = raw.split_whitespace().collect();
113 let options = fields.get(3).copied().unwrap_or("");
114 let priority = PRI.captures(options).and_then(|c| c[1].parse::<i64>().ok());
115 let entry = json!({
116 "line": json_num(js_num(&line[..index])),
117 "spec": clean_text(fields.first().copied().unwrap_or(""), 200),
118 "resolved": resolved,
119 "type": clean_text(fields.get(2).copied().unwrap_or(""), 16),
120 "options": clean_text(options, 120),
121 "priority": priority,
122 "commented": raw.trim().starts_with('#'),
123 "managedByChj": raw.contains("# CH-J chj.system-monitor")
124 });
125 (entry["type"] == "swap" && entry["commented"] != true).then_some(entry)
126 })
127 .collect();
128 let units: Vec<Value> = lines(section("units"))
129 .into_iter()
130 .map(|line| {
131 let p: Vec<&str> = line.split('|').collect();
132 json!({ "unit": clean_text(p.first().copied().unwrap_or(""), 120), "active": clean_text(p.get(1).copied().unwrap_or(""), 20), "state": clean_text(p.get(2).copied().unwrap_or(""), 20) })
133 })
134 .collect();
135 let files: Vec<Value> = lines(section("files"))
136 .into_iter()
137 .map(|line| {
138 let p: Vec<&str> = line.split('|').collect();
139 let f = |i: usize| p.get(i).copied().unwrap_or("");
140 json!({
141 "path": clean_text(f(0), 200),
142 "kind": f(1),
143 "fsType": if f(2) == "-" { Value::Null } else { json!(clean_text(f(2), 24)) },
144 "size": number(f(3)),
145 "owner": if f(4) == "0" && f(1) != "file" { Value::Null } else { json!(clean_text(f(4), 32)) },
146 "mode": if f(5) == "-" { Value::Null } else { json!(clean_text(f(5), 8)) },
147 "managed": f(6) == "managed",
148 "diskFree": number(f(7))
149 })
150 })
151 .collect();
152 let locations: Vec<Value> = lines(section("locations"))
153 .into_iter()
154 .map(|line| {
155 let p: Vec<&str> = line.split('|').collect();
156 json!({ "dir": p.first().copied().unwrap_or(""), "fsType": clean_text(p.get(1).copied().unwrap_or(""), 24), "free": number(p.get(2).copied().unwrap_or("")) })
157 })
158 .collect();
159 let zram: Vec<Value> = lines(section("zram"))
160 .into_iter()
161 .map(|line| {
162 let p: Vec<&str> = line.split('|').collect();
163 let values: Vec<f64> = p.get(2).copied().unwrap_or("").split(',').map(js_num).collect();
164 let at = |i: usize| values.get(i).map(|v| json_num(*v)).unwrap_or(Value::Null);
165 json!({ "device": p.first().copied().unwrap_or(""), "diskSize": number(p.get(1).copied().unwrap_or("")), "originalData": at(0), "compressedData": at(1), "memoryUsed": at(2) })
166 })
167 .collect();
168 // Areas keyed by path in insertion order.
169 let mut areas: Vec<(String, Value)> = Vec::new();
170 for entry in &active {
171 let mut area = entry.clone();
172 area["active"] = json!(true);
173 let path = js_string(&entry["path"]);
174 match areas.iter_mut().find(|(p, _)| *p == path) {
175 Some(slot) => slot.1 = area,
176 None => areas.push((path, area)),
177 }
178 }
179 for file in &files {
180 let path = js_string(&file["path"]);
181 if !areas.iter().any(|(p, _)| *p == path) {
182 areas.push((path.clone(), json!({ "path": path, "type": file["kind"], "size": file["size"], "used": 0, "priority": null, "active": false })));
183 }
184 }
185 let matches = |entry: &Value, path: &str| entry["spec"] == path || entry["resolved"] == path;
186 for entry in &fstab {
187 let resolved = entry["resolved"].as_str().filter(|r| r.starts_with('/'));
188 let identity = resolved.map(str::to_string).unwrap_or_else(|| js_string(&entry["spec"]));
189 if identity.starts_with('/') && !areas.iter().any(|(key, _)| matches(entry, key)) {
190 areas.push((identity.clone(), json!({ "path": identity, "type": if identity.starts_with("/dev/") { "partition" } else { "file" }, "size": null, "used": 0, "priority": null, "active": false })));
191 }
192 }
193 let mut swap_areas: Vec<Value> = areas
194 .into_iter()
195 .map(|(path, mut area)| {
196 let file = files.iter().find(|f| f["path"] == path.as_str());
197 let entries: Vec<&Value> = fstab.iter().filter(|e| matches(e, &path)).collect();
198 let persistent = entries.first().copied();
199 let unit = units.iter().find(|u| u["unit"] == unit_name(&path).as_str()).cloned();
200 let kind = js_string(&area["type"]);
201 let management = if kind == "zram" {
202 "read-only"
203 } else if kind == "partition" {
204 "partition"
205 } else if file.is_some_and(|f| f["managed"] == true) || MANAGED_PATH.is_match(&path) {
206 "managed"
207 } else {
208 "foreign-file"
209 };
210 let active = area["active"] == true;
211 let priority = area["priority"].as_f64();
212 let persistent_priority = persistent.and_then(|p| p["priority"].as_f64());
213 let priority_mode = match persistent {
214 Some(_) if persistent_priority.is_none() => "auto",
215 Some(_) => "custom",
216 None if active && priority.is_some_and(|p| p >= 0.0) => "custom",
217 None => "auto",
218 };
219 let pending = active
220 && persistent.is_some()
221 && match persistent_priority {
222 None => priority.is_some_and(|p| p >= 0.0),
223 Some(pp) => Some(pp) != priority,
224 };
225 let allowed: Vec<&str> = match management {
226 "read-only" => vec![],
227 "partition" | "foreign-file" => vec!["activate", "deactivate", "priority", "persist"],
228 _ => vec!["activate", "deactivate", "priority", "persist", "resize", "delete"],
229 };
230 let map = area.as_object_mut().unwrap();
231 map.insert("fsType".into(), file.map(|f| f["fsType"].clone()).filter(|v| !v.is_null()).unwrap_or(Value::Null));
232 map.insert("mode".into(), file.map(|f| f["mode"].clone()).filter(|v| !v.is_null()).unwrap_or(Value::Null));
233 map.insert("owner".into(), file.map(|f| f["owner"].clone()).filter(|v| !v.is_null() && *v != "").unwrap_or(Value::Null));
234 map.insert("persistent".into(), json!(persistent.is_some()));
235 map.insert("persistentSpec".into(), persistent.map(|p| p["spec"].clone()).unwrap_or(Value::Null));
236 map.insert("persistentAmbiguous".into(), json!(entries.len() > 1));
237 map.insert("persistentPriority".into(), persistent.map(|p| p["priority"].clone()).unwrap_or(Value::Null));
238 map.insert("effectivePriority".into(), if active { area_priority(priority) } else { Value::Null });
239 map.insert("priorityMode".into(), json!(priority_mode));
240 map.insert("priorityPending".into(), json!(pending));
241 map.insert("persistentByChj".into(), json!(persistent.is_some_and(|p| p["managedByChj"] == true)));
242 map.insert("systemdUnit".into(), unit.unwrap_or(Value::Null));
243 map.insert("management".into(), json!(management));
244 map.insert("allowedActions".into(), json!(allowed));
245 area
246 })
247 .collect();
248 // Precedence: the kernel fills higher priorities first; equal priorities are used round-robin.
249 let mut priorities: Vec<i64> = swap_areas.iter().filter(|a| a["active"] == true).filter_map(|a| a["priority"].as_f64().map(|p| p as i64)).collect::<HashSet<_>>().into_iter().collect();
250 priorities.sort_by(|a, b| b.cmp(a));
251 let snapshot = swap_areas.clone();
252 for area in swap_areas.iter_mut() {
253 let active = area["active"] == true;
254 let priority = area["priority"].as_f64().map(|p| p as i64);
255 area["precedence"] = if active { json!(priority.and_then(|p| priorities.iter().position(|x| *x == p)).map(|i| i as i64 + 1).unwrap_or(0)) } else { Value::Null };
256 area["sharedPriority"] = json!(active && snapshot.iter().filter(|o| o["active"] == true && o["priority"].as_f64().map(|p| p as i64) == priority).count() > 1);
257 }
258 let meta_flag = |key: &str| meta.get(key).map(String::as_str) == Some("1");
259 json!({
260 "platform": "linux",
261 "root": meta_flag("root"),
262 "systemd": meta_flag("systemd"),
263 "btrfsMkswapfile": meta_flag("btrfsMkswapfile"),
264 "os": clean_text(meta.get("os").map(String::as_str).unwrap_or(""), 120),
265 "memory": { "total": meminfo.get("MemTotal").cloned().unwrap_or(Value::Null), "available": meminfo.get("MemAvailable").cloned().unwrap_or(Value::Null), "swapTotal": meminfo.get("SwapTotal").cloned().unwrap_or(Value::Null), "swapFree": meminfo.get("SwapFree").cloned().unwrap_or(Value::Null) },
266 "swapAreas": swap_areas,
267 "fstab": fstab,
268 "units": units,
269 "locations": locations,
270 "zram": zram
271 })
274fn area_priority(priority: Option<f64>) -> Value {
275 priority.map(Value::from).unwrap_or(Value::Null)
278fn parse_steps(stdout: &str) -> Vec<Value> {
279 STEP.captures_iter(stdout).map(|c| json!({ "step": &c[1], "status": &c[2], "message": clean_text(&c[3], 300) })).collect()
282fn normalize_pagefile(value: &Value) -> String {
283 let lower = js_string(value).to_lowercase();
284 let stripped = lower.strip_prefix("\\??\\").unwrap_or(&lower);
285 stripped.split(' ').next().unwrap_or("").to_string()
288pub fn map_windows_state(raw: &Value) -> Value {
289 let mb = |value: &Value| match num_value(value) {
290 Value::Null => Value::Null,
291 v => json!(v.as_f64().unwrap_or(0.0) * 1_048_576.0),
292 };
293 let list = |key: &str| raw[key].as_array().cloned().unwrap_or_default();
294 let configured: Vec<Value> = list("configured")
295 .iter()
296 .map(|e| json!({ "name": clean_text(&js_string(&e["name"]), 260), "initial": mb(&e["initialMb"]), "maximum": mb(&e["maximumMb"]), "systemManagedSize": js_number_value(&e["initialMb"]) == Some(0.0) && js_number_value(&e["maximumMb"]) == Some(0.0) }))
297 .collect();
298 let active: Vec<Value> = list("active").iter().map(|e| json!({ "name": clean_text(&js_string(&e["name"]), 260), "allocated": mb(&e["allocatedMb"]), "used": mb(&e["currentMb"]), "peak": mb(&e["peakMb"]) })).collect();
299 let mut registry: Vec<String> = list("registryPagingFiles").iter().map(normalize_pagefile).collect();
300 registry.sort();
301 let mut existing: Vec<String> = list("existingPageFiles").iter().map(normalize_pagefile).collect();
302 existing.sort();
303 let mut names: Vec<String> = Vec::new();
304 for name in configured.iter().chain(active.iter()).map(|e| js_string(&e["name"]).to_lowercase()) {
305 if !names.contains(&name) {
306 names.push(name);
307 }
308 }
309 let volumes = list("volumes");
310 let pagefiles: Vec<Value> = names
311 .iter()
312 .map(|name| {
313 let setting = configured.iter().find(|e| js_string(&e["name"]).to_lowercase() == *name);
314 let usage = active.iter().find(|e| js_string(&e["name"]).to_lowercase() == *name);
315 let volume = volumes.iter().find(|v| name.starts_with(&js_string(&v["drive"]).to_lowercase()));
316 let display = setting.map(|s| js_string(&s["name"])).or_else(|| usage.map(|u| js_string(&u["name"]))).unwrap_or_default();
317 json!({
318 "name": display,
319 "drive": display.chars().take(2).collect::<String>().to_uppercase(),
320 "configured": setting.is_some(),
321 "active": usage.is_some(),
322 "initial": setting.map(|s| s["initial"].clone()).unwrap_or(Value::Null),
323 "maximum": setting.map(|s| s["maximum"].clone()).unwrap_or(Value::Null),
324 "systemManagedSize": setting.map(|s| s["systemManagedSize"].clone()).unwrap_or(Value::Null),
325 "allocated": usage.map(|u| u["allocated"].clone()).unwrap_or(Value::Null),
326 "used": usage.map(|u| u["used"].clone()).unwrap_or(Value::Null),
327 "peak": usage.map(|u| u["peak"].clone()).unwrap_or(Value::Null),
328 "volumeFree": volume.map(|v| v["free"].clone()).unwrap_or(Value::Null)
329 })
330 })
331 .collect();
332 let pending_truthy = !matches!(&raw["pending"], Value::Null | Value::Bool(false)) && raw["pending"] != 0 && raw["pending"] != "";
333 json!({
334 "platform": "windows",
335 "elevated": raw["elevated"] == true,
336 "automatic": if raw["automatic"].is_boolean() { raw["automatic"].clone() } else { Value::Null },
337 "pagefiles": pagefiles,
338 // Commit limit = physical memory + pagefiles; it is not the pagefile size.
339 "commit": { "limit": num_value(&raw["commitLimit"]), "committed": num_value(&raw["committed"]) },
340 "volumes": volumes.iter().map(|v| json!({ "drive": clean_text(&js_string(&v["drive"]), 3), "format": clean_text(&js_string(&v["format"]), 12), "free": num_value(&v["free"]), "total": num_value(&v["total"]) })).collect::<Vec<_>>(),
341 "systemDrive": clean_text(&js_string(&raw["systemDrive"]), 3),
342 "crashDumpEnabled": js_number_value(&raw["crashDumpEnabled"]).filter(|n| n.is_finite()).unwrap_or(0.0),
343 "restartPending": pending_truthy || (raw["automatic"] == false && !registry.is_empty() && registry != existing),
344 "pendingChanges": raw["pending"]["changes"].as_array().cloned().unwrap_or_default().iter().map(|c| json!({ "at": c["at"], "description": clean_text(&js_string(&c["description"]), 300) })).collect::<Vec<_>>(),
345 "bootTime": if raw["bootTime"].is_null() || raw["bootTime"] == "" { Value::Null } else { raw["bootTime"].clone() },
346 "unavailable": list("errors").iter().map(|e| clean_text(&js_string(e), 200)).collect::<Vec<_>>()
347 })
350fn windows_error(error: CoreError) -> CoreError {
351 match error.code.strip_prefix("METRICS_") {
352 Some(rest) => CoreError { code: format!("VM_{rest}"), ..error },
353 None => error,
354 }
357fn merge(base: Value, extra: Value) -> Value {
358 let mut map = base.as_object().cloned().unwrap_or_default();
359 for (k, v) in extra.as_object().cloned().unwrap_or_default() {
360 map.insert(k, v);
361 }
362 Value::Object(map)
365pub struct VirtualMemoryService {
366 pub sessions: Arc<SessionManager>,
367 pub executor: Arc<PrivilegedExecutor>,
368 pub logger: Arc<Logger>,
369 running: Mutex<HashSet<String>>,
372fn sudo_password(payload: &Value) -> Option<String> {
373 payload["sudoPassword"].as_str().filter(|s| !s.is_empty()).map(str::to_string)
376impl VirtualMemoryService {
377 pub fn new(sessions: Arc<SessionManager>, executor: Arc<PrivilegedExecutor>, logger: Arc<Logger>) -> Self {
378 Self { sessions, executor, logger, running: Mutex::new(HashSet::new()) }
379 }
381 async fn platform(&self, session_id: &str) -> CoreResult<Value> {
382 let record = self.sessions.require_connected(session_id)?;
383 let platform = detect_platform(&self.sessions, &record, false).await?;
384 if platform["os"] == "unknown" {
385 return Err(fail("VM_PLATFORM_UNSUPPORTED", &format!("The remote operating system could not be identified: {}.", platform["reason"].as_str().filter(|s| !s.is_empty()).unwrap_or("unknown"))));
386 }
387 Ok(platform)
388 }
390 async fn windows_script(&self, session_id: &str, args: Value, timeout_ms: u64) -> CoreResult<Value> {
391 let record = self.sessions.require_connected(session_id)?;
392 run_windows_script(&self.sessions, &record, PAGEFILE_SCRIPT, &args, Duration::from_millis(timeout_ms), 4 * 1024 * 1024, "METRICS_").await.map_err(windows_error)
393 }
395 /// `options`: `{ sudoPassword?, privileged? }`.
396 pub async fn read(&self, session_id: &str, options: &Value) -> CoreResult<Value> {
397 let platform = self.platform(session_id).await?;
398 if platform["os"] == "windows" {
399 let raw = self.windows_script(session_id, json!({ "op": "inspect" }), 90_000).await?;
400 let elevated = raw["elevated"] == true;
401 return Ok(merge(
402 map_windows_state(&raw),
403 json!({ "canManage": elevated, "readOnlyReason": if elevated { Value::Null } else { json!("Changing the pagefile requires an SSH session with an elevated administrator token.") }, "collectedAt": now_iso() }),
404 ));
405 }
406 let privilege = self.executor.probe(session_id, false).await?;
407 let privileged = sudo_password(options).is_some() || options["privileged"] == true || ["root", "sudo-nopasswd"].contains(&privilege["mode"].as_str().unwrap_or(""));
408 let run = self
409 .executor
410 .run(
411 session_id,
412 RunOptions {
413 script: SWAP_SCRIPT.into(),
414 args: vec!["inspect".into()],
415 privileged,
416 sudo_password: sudo_password(options),
417 purpose: "system.swap.inspect".into(),
418 error_prefix: "VM_".into(),
419 timeout: Duration::from_secs(60),
420 ..Default::default()
421 },
422 )
423 .await?;
424 let none = privilege["mode"] == "none";
425 Ok(merge(
426 parse_linux_inspect(&run.stdout),
427 json!({ "privilege": privilege, "canManage": !none, "readOnlyReason": if none { json!("The SSH account has no sudo rights; swap is shown read-only.") } else { Value::Null }, "collectedAt": now_iso() }),
428 ))
429 }
431 pub async fn manage(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
432 let platform = self.platform(session_id).await?;
433 if !self.running.lock().insert(session_id.to_string()) {
434 return Err(fail("VM_BUSY", "Another virtual memory change is still running for this session."));
435 }
436 let result = if platform["os"] == "windows" { self.manage_windows(session_id, payload).await } else { self.manage_linux(session_id, payload).await };
437 self.running.lock().remove(session_id);
438 result
439 }
441 async fn manage_linux(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
442 let action = js_string(&payload["action"]);
443 if !LINUX_ACTIONS.contains(&action.as_str()) {
444 return Err(fail("VM_ACTION_INVALID", "Unsupported swap action."));
445 }
446 let target = js_string(&payload["path"]);
447 if !ANY_SWAP_PATH.is_match(&target) || target.contains("..") {
448 return Err(fail("VM_SWAP_PATH_INVALID", "Invalid swap path."));
449 }
450 let sized = ["create", "resize"].contains(&action.as_str());
451 if ["create", "resize", "delete"].contains(&action.as_str()) && !MANAGED_PATH.is_match(&target) {
452 return Err(fail("VM_SWAP_PATH_INVALID", "CH-J creates, resizes and deletes swap files only as /swapfile*, /swap/<name> or /var/lib/swap/<name>."));
453 }
454 if target.starts_with("/dev/zram") {
455 return Err(fail("VM_SWAP_ZRAM_READ_ONLY", "zram is shown read-only; CH-J does not configure it."));
456 }
457 let size = if sized { js_number_value(&payload["sizeMiB"]).unwrap_or(f64::NAN) } else { 0.0 };
458 if sized && (size.fract() != 0.0 || !(64.0..=65536.0).contains(&size)) {
459 return Err(fail("VM_SWAP_SIZE_INVALID", "Size must be a whole number of MiB between 64 MiB and 64 GiB."));
460 }
461 // "auto"/-1 = kernel-assigned, 0-32767 = explicit, "keep" = reuse the configured priority (activate/resize/persist).
462 let raw = &payload["priority"];
463 let priority: Result<i64, ()> = match raw {
464 Value::Null => Ok(-1),
465 Value::String(s) if s.is_empty() || s == "auto" => Ok(-1),
466 Value::String(s) if s == "keep" => Err(()),
467 other => match js_number_value(other) {
468 Some(n) if n.fract() == 0.0 && (-1.0..=32767.0).contains(&n) => Ok(n as i64),
469 _ => return Err(fail("VM_SWAP_PRIORITY_INVALID", "Priority must be automatic or a whole number 0-32767.")),
470 },
471 };
472 if priority.is_err() && ["create", "priority"].contains(&action.as_str()) {
473 return Err(fail("VM_SWAP_PRIORITY_INVALID", "Priority must be automatic or a whole number 0-32767."));
474 }
475 let priority_arg = priority.map(|p| p.to_string()).unwrap_or_else(|_| "keep".into());
476 let apply = if payload.get("apply").is_none() || payload["apply"].is_null() { "now".to_string() } else { js_string(&payload["apply"]) };
477 if apply != "now" && apply != "later" {
478 return Err(fail("VM_ARGUMENTS_INVALID", "Apply must be now or later."));
479 }
480 let persist = match payload["persist"] {
481 Value::Bool(true) => "1",
482 Value::Bool(false) => "0",
483 _ => "keep",
484 };
485 if ["create", "persist"].contains(&action.as_str()) && persist == "keep" {
486 return Err(fail("VM_ARGUMENTS_INVALID", "Choose whether the swap space is activated after restart."));
487 }
488 let run = self
489 .executor
490 .run(
491 session_id,
492 RunOptions {
493 script: SWAP_SCRIPT.into(),
494 args: vec!["manage".into(), action.clone(), target.clone(), (size as i64).to_string(), priority_arg, persist.into(), apply],
495 privileged: true,
496 sudo_password: sudo_password(payload),
497 purpose: format!("system.swap.{action}"),
498 error_prefix: "VM_".into(),
499 timeout: Duration::from_secs(30 * 60),
500 ..Default::default()
501 },
502 )
503 .await?;
504 self.logger.warn("Swap configuration changed.", json!({ "sessionId": session_id, "action": action, "path": target }));
505 let state = self.read(session_id, &json!({ "sudoPassword": payload["sudoPassword"], "privileged": true })).await.ok();
506 let sections = split_sections(&run.stdout);
507 Ok(json!({ "action": action, "path": target, "result": "ok", "steps": parse_steps(&run.stdout), "active": parse_proc_swaps(sections.get("swaps").map(String::as_str).unwrap_or("")), "state": state }))
508 }
510 async fn manage_windows(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
511 let action = js_string(&payload["action"]);
512 if !WINDOWS_ACTIONS.contains(&action.as_str()) {
513 return Err(fail("VM_ACTION_INVALID", "Unsupported pagefile action."));
514 }
515 let mut args = json!({ "op": "apply", "action": action });
516 if action != "automatic" {
517 let drive = js_string(&payload["drive"]).to_uppercase();
518 let bytes = drive.as_bytes();
519 if !(bytes.len() == 2 && bytes[0].is_ascii_uppercase() && bytes[1] == b':') {
520 return Err(fail("VM_PAGEFILE_DRIVE_INVALID", "Invalid drive letter."));
521 }
522 args["drive"] = json!(drive);
523 }
524 if action == "set" {
525 let initial = js_number_value(&payload["initialMb"]).unwrap_or(f64::NAN);
526 let maximum = js_number_value(&payload["maximumMb"]).unwrap_or(f64::NAN);
527 let system_managed = initial == 0.0 && maximum == 0.0;
528 if initial.fract() != 0.0 || maximum.fract() != 0.0 || (!system_managed && (initial < 16.0 || maximum < initial || maximum > 4_194_304.0)) {
529 return Err(fail("VM_PAGEFILE_SIZE_INVALID", "Initial and maximum size must be whole MB values of at least 16 with initial <= maximum, or both 0 for a system-managed size."));
530 }
531 args["initialMb"] = json!(initial as i64);
532 args["maximumMb"] = json!(maximum as i64);
533 }
534 if action == "remove" {
535 args["confirmLast"] = json!(payload["confirmLast"] == true);
536 args["confirmCrashDump"] = json!(payload["confirmCrashDump"] == true);
537 }
538 let raw = self.windows_script(session_id, args, 120_000).await?;
539 self.logger.warn("Windows pagefile configuration changed (restart pending).", json!({ "sessionId": session_id, "action": action }));
540 Ok(json!({ "action": action, "result": raw["result"], "backup": clean_text(&js_string(&raw["backup"]), 300), "restartRequired": true, "state": merge(map_windows_state(&raw["state"]), json!({ "canManage": true, "collectedAt": now_iso() })) }))
541 }
544#[cfg(test)]
545mod tests {
546 use super::*;
548 #[test]
549 fn parses_linux_and_windows_state() {
550 let stdout = "\x1eCHJ_SECTION=meta\nroot=1\nsystemd=0\nos=Debian\n\x1eCHJ_SECTION=meminfo\nMemTotal: 2048 kB\nSwapTotal: 1024 kB\n\x1eCHJ_SECTION=swaps\nFilename Type Size Used Priority\n/swapfile file 1024 0 -2\n/dev/zram0 partition 512 0 100\n\x1eCHJ_SECTION=fstab\n3|/swapfile|/swapfile none swap sw,pri=5 0 0 # CH-J chj.system-monitor\n\x1eCHJ_SECTION=files\n/swapfile|file|ext4|1048576|0|600|managed|1000\n";
551 let state = parse_linux_inspect(stdout);
552 assert_eq!(state["memory"]["total"], 2048.0 * 1024.0);
553 let areas = state["swapAreas"].as_array().unwrap();
554 assert_eq!(areas[0]["management"], "managed");
555 assert_eq!(areas[0]["priorityMode"], "custom");
556 assert_eq!(areas[0]["priorityPending"], true);
557 assert_eq!(areas[0]["persistentByChj"], true);
558 assert_eq!(areas[1]["type"], "zram");
559 assert_eq!(areas[1]["precedence"], 1);
560 assert_eq!(areas[0]["precedence"], 2);
561 let windows = map_windows_state(&json!({ "elevated": true, "automatic": false, "configured": [{ "name": "C:\\pagefile.sys", "initialMb": 0, "maximumMb": 0 }], "registryPagingFiles": ["c:\\pagefile.sys 0 0"], "existingPageFiles": [] }));
562 assert_eq!(windows["pagefiles"][0]["systemManagedSize"], true);
563 assert_eq!(windows["restartPending"], true);
564 assert_eq!(unit_name("/swap/my-file"), "swap-my\\x2dfile.swap");
565 }
567 #[tokio::test]
568 async fn priority_and_apply_validation_before_remote_execution() {
569 use crate::core::{profiles::ProfileService, vault::VaultStore};
570 let dir=tempfile::tempdir().unwrap();
571 let sessions=SessionManager::new(Arc::new(ProfileService::new(Arc::new(VaultStore::new(dir.path())))),Arc::new(Logger::new(dir.path())),Arc::new(|_,_|{}));
572 let service=VirtualMemoryService::new(sessions.clone(),Arc::new(PrivilegedExecutor::new(sessions)),Arc::new(Logger::new(dir.path())));
573 for priority in [json!(-3),json!(32768),json!(1.5),json!("abc"),json!("keep")] {
574 let error=service.manage_linux("absent",&json!({"action":"priority","path":"/swapfile-chj","priority":priority})).await.unwrap_err();
575 assert_eq!(error.code,"VM_SWAP_PRIORITY_INVALID","{priority}");
576 }
577 assert_eq!(service.manage_linux("absent",&json!({"action":"priority","path":"/swapfile-chj","priority":5,"apply":"soon"})).await.unwrap_err().code,"VM_ARGUMENTS_INVALID");
578 for priority in [Value::Null,json!(""),json!("auto"),json!(-1),json!(0),json!(100),json!(32767)] {
579 for apply in ["now","later"] {
580 assert_eq!(service.manage_linux("absent",&json!({"action":"priority","path":"/swapfile-chj","priority":priority,"apply":apply})).await.unwrap_err().code,"SESSION_NOT_CONNECTED","{priority}/{apply}");
581 }
582 }
583 for action in ["activate","resize","persist"] {
584 assert_eq!(service.manage_linux("absent",&json!({"action":action,"path":"/swapfile-chj","priority":"keep","sizeMiB":128,"persist":true})).await.unwrap_err().code,"SESSION_NOT_CONNECTED");
585 }
586 }
588 fn priority_state(swaps:&str,fstab:&str)->Value {
589 parse_linux_inspect(&format!("\x1eCHJ_SECTION=meta\nroot=1\n\x1eCHJ_SECTION=swaps\nFilename Type Size Used Priority\n{swaps}\x1eCHJ_SECTION=fstab\n{fstab}\x1eCHJ_SECTION=end\n"))
590 }
591 fn area(state:&Value,path:&str)->Value {state["swapAreas"].as_array().unwrap().iter().find(|a|a["path"]==path).unwrap().clone()}
592 #[test]
593 fn automatic_explicit_equal_and_persistent_priority_matrix() {
594 let state=priority_state("/swap/a file 1024 0 -2\n/swap/b file 1024 0 -3\n","1|/swap/a|/swap/a none swap sw 0 0 # CH-J chj.system-monitor\n");
595 let a=area(&state,"/swap/a");assert_eq!(a["priorityMode"],"auto");assert_eq!(a["priorityPending"],false);assert_eq!(a["precedence"],1);assert_eq!(area(&state,"/swap/b")["precedence"],2);assert!(a["persistentPriority"].is_null());
596 let state=priority_state("/swap/a file 1024 0 100\n/swap/b file 1024 0 10\n","1|/swap/a|/swap/a none swap sw,pri=100 0 0\n2|/swap/b|/swap/b none swap sw,pri=40 0 0\n");
597 assert_eq!(area(&state,"/swap/a")["priorityPending"],false);assert_eq!(area(&state,"/swap/b")["priorityPending"],true);assert_eq!(area(&state,"/swap/b")["persistentPriority"],40);assert_eq!(area(&state,"/swap/a")["precedence"],1);
598 let state=priority_state("/swap/a file 1024 0 5\n/swap/b file 1024 0 5\n","7|/dev/chj-test|UUID=fixture none swap sw,pri=1 0 0\n");
599 for path in ["/swap/a","/swap/b"] {let a=area(&state,path);assert_eq!(a["precedence"],1);assert_eq!(a["sharedPriority"],true);assert_eq!(a["persistent"],false);}
600 let p=area(&state,"/dev/chj-test");assert_eq!(p["active"],false);assert_eq!(p["persistentSpec"],"UUID=fixture");assert_eq!(p["management"],"partition");
601 }
SHA-2564c43d18333252efe4a49866320cd7879855c768e1be044e2c6e5dd92222ef2b2

CH-J Proprietary Software License 1.14 · Sursa este furnizată în cadrul CH-J Proprietary Software License 1.14. Disponibilitatea sa nu modifică termenii licenţei sau nu acordă drepturi suplimentare.

Archive SHA-25677380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Fișiere