CH-J Toolsمدیریت سرور از طریق SSH
منو
منبع منتشرشده

CH-J Tools

پوشه‌ها و فایل‌های یک نسخهٔ مشخص از برنامه را مرور کنید.

دانلود ZIP منبع
CH-J / Server Manager 1704 فایل
0.1.2-beta افزوده‌شده: · ساخته‌شده:
virtual_memory.rs افزوده‌شده: تاریخ ثبت نشده است ۳۳٫۶ KB · 601 خط
کد دانلود فایل
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;
20static 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());
21static ANY_SWAP_PATH: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/-]{1,199}$").unwrap());
22static ZRAM: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/dev/zram\d+$").unwrap());
23static PRI: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?:^|,)pri=(-?\d+)").unwrap());
24static STEP: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_STEP=([a-z-]+)\|([a-z]+)\|([^\n]*)\n").unwrap());
25const LINUX_ACTIONS: [&str; 7] = ["create", "activate", "deactivate", "priority", "persist", "resize", "delete"];
26const WINDOWS_ACTIONS: [&str; 3] = ["automatic", "set", "remove"];
28fn fail(code: &str, message: &str) -> CoreError {
29 CoreError::new(code, message)
32/// 0.1.0 `number()`: empty/null/non-finite ← null.
33fn number(text: &str) -> Value {
34 if text.is_empty() {
35 return Value::Null;
36 }
37 match js_number_value(&json!(text)) {
38 Some(n) if n.is_finite() => json!(n),
39 _ => Value::Null,
40 }
43fn num_value(value: &Value) -> Value {
44 match value {
45 Value::Null => Value::Null,
46 Value::String(s) if s.is_empty() => Value::Null,
47 other => match js_number_value(other) {
48 Some(n) if n.is_finite() => json!(n),
49 _ => Value::Null,
50 },
51 }
54fn js_num(text: &str) -> f64 {
55 js_number_value(&json!(text)).unwrap_or(f64::NAN)
58fn json_num(n: f64) -> Value {
59 if n.is_finite() { json!(n) } else { Value::Null }
62pub fn parse_proc_swaps(text: &str) -> Vec<Value> {
63 lines(text)
64 .into_iter()
65 .skip(1)
66 .map(|line| {
67 let fields: Vec<&str> = line.split_whitespace().collect();
68 let field = |i: usize| fields.get(i).copied().unwrap_or("");
69 let path = field(0).replace("\\040", " ");
70 let kind = field(1);
71 json!({
72 "path": clean_text(&path, 200),
73 "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) },
74 "size": json_num(js_num(field(2)) * 1024.0),
75 "used": json_num(js_num(field(3)) * 1024.0),
76 "priority": json_num(js_num(field(4)))
77 })
78 })
79 .collect()
82fn unit_name(path: &str) -> String {
83 format!("{}.swap", path.trim_start_matches('/').replace('-', "\\x2d").replace('/', "-"))
86pub fn parse_linux_inspect(stdout: &str) -> Value {
87 let sections = split_sections(stdout);
88 let section = |name: &str| sections.get(name).map(String::as_str).unwrap_or("");
89 let meta = key_values(section("meta"));
90 let mut meminfo = Map::new();
91 for line in lines(section("meminfo")) {
92 let mut parts = line.splitn(2, ':');
93 let key = parts.next().unwrap_or("").to_string();
94 let value = parts.next().unwrap_or("").trim();
95 let digits: String = value.chars().take_while(|c| c.is_ascii_digit() || *c == '-').collect();
96 meminfo.insert(key, digits.parse::<f64>().map(|n| json!(n * 1024.0)).unwrap_or(Value::Null));
97 }
98 let active = parse_proc_swaps(section("swaps"));
99 let fstab: Vec<Value> = lines(section("fstab"))
100 .into_iter()
101 .filter_map(|line| {
102 // line|canonical identity|raw line (older helpers sent line|raw).
103 let index = line.find('|')?;
104 let second = line[index + 1..].find('|').map(|i| i + index + 1);
105 let with_canonical = second.is_some_and(|s| !line[index + 1..s].chars().any(char::is_whitespace));
106 let (raw, resolved) = match (with_canonical, second) {
107 (true, Some(s)) => (&line[s + 1..], json!(clean_text(&line[index + 1..s], 200))),
108 _ => (&line[index + 1..], Value::Null),
109 };
110 let fields: Vec<&str> = raw.split_whitespace().collect();
111 let options = fields.get(3).copied().unwrap_or("");
112 let priority = PRI.captures(options).and_then(|c| c[1].parse::<i64>().ok());
113 let entry = json!({
114 "line": json_num(js_num(&line[..index])),
115 "spec": clean_text(fields.first().copied().unwrap_or(""), 200),
116 "resolved": resolved,
117 "type": clean_text(fields.get(2).copied().unwrap_or(""), 16),
118 "options": clean_text(options, 120),
119 "priority": priority,
120 "commented": raw.trim().starts_with('#'),
121 "managedByChj": raw.contains("# CH-J chj.system-monitor")
122 });
123 (entry["type"] == "swap" && entry["commented"] != true).then_some(entry)
124 })
125 .collect();
126 let units: Vec<Value> = lines(section("units"))
127 .into_iter()
128 .map(|line| {
129 let p: Vec<&str> = line.split('|').collect();
130 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) })
131 })
132 .collect();
133 let files: Vec<Value> = lines(section("files"))
134 .into_iter()
135 .map(|line| {
136 let p: Vec<&str> = line.split('|').collect();
137 let f = |i: usize| p.get(i).copied().unwrap_or("");
138 json!({
139 "path": clean_text(f(0), 200),
140 "kind": f(1),
141 "fsType": if f(2) == "-" { Value::Null } else { json!(clean_text(f(2), 24)) },
142 "size": number(f(3)),
143 "owner": if f(4) == "0" && f(1) != "file" { Value::Null } else { json!(clean_text(f(4), 32)) },
144 "mode": if f(5) == "-" { Value::Null } else { json!(clean_text(f(5), 8)) },
145 "managed": f(6) == "managed",
146 "diskFree": number(f(7))
147 })
148 })
149 .collect();
150 let locations: Vec<Value> = lines(section("locations"))
151 .into_iter()
152 .map(|line| {
153 let p: Vec<&str> = line.split('|').collect();
154 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("")) })
155 })
156 .collect();
157 let zram: Vec<Value> = lines(section("zram"))
158 .into_iter()
159 .map(|line| {
160 let p: Vec<&str> = line.split('|').collect();
161 let values: Vec<f64> = p.get(2).copied().unwrap_or("").split(',').map(js_num).collect();
162 let at = |i: usize| values.get(i).map(|v| json_num(*v)).unwrap_or(Value::Null);
163 json!({ "device": p.first().copied().unwrap_or(""), "diskSize": number(p.get(1).copied().unwrap_or("")), "originalData": at(0), "compressedData": at(1), "memoryUsed": at(2) })
164 })
165 .collect();
166 // Areas keyed by path in insertion order.
167 let mut areas: Vec<(String, Value)> = Vec::new();
168 for entry in &active {
169 let mut area = entry.clone();
170 area["active"] = json!(true);
171 let path = js_string(&entry["path"]);
172 match areas.iter_mut().find(|(p, _)| *p == path) {
173 Some(slot) => slot.1 = area,
174 None => areas.push((path, area)),
175 }
176 }
177 for file in &files {
178 let path = js_string(&file["path"]);
179 if !areas.iter().any(|(p, _)| *p == path) {
180 areas.push((path.clone(), json!({ "path": path, "type": file["kind"], "size": file["size"], "used": 0, "priority": null, "active": false })));
181 }
182 }
183 let matches = |entry: &Value, path: &str| entry["spec"] == path || entry["resolved"] == path;
184 for entry in &fstab {
185 let resolved = entry["resolved"].as_str().filter(|r| r.starts_with('/'));
186 let identity = resolved.map(str::to_string).unwrap_or_else(|| js_string(&entry["spec"]));
187 if identity.starts_with('/') && !areas.iter().any(|(key, _)| matches(entry, key)) {
188 areas.push((identity.clone(), json!({ "path": identity, "type": if identity.starts_with("/dev/") { "partition" } else { "file" }, "size": null, "used": 0, "priority": null, "active": false })));
189 }
190 }
191 let mut swap_areas: Vec<Value> = areas
192 .into_iter()
193 .map(|(path, mut area)| {
194 let file = files.iter().find(|f| f["path"] == path.as_str());
195 let entries: Vec<&Value> = fstab.iter().filter(|e| matches(e, &path)).collect();
196 let persistent = entries.first().copied();
197 let unit = units.iter().find(|u| u["unit"] == unit_name(&path).as_str()).cloned();
198 let kind = js_string(&area["type"]);
199 let management = if kind == "zram" {
200 "read-only"
201 } else if kind == "partition" {
202 "partition"
203 } else if file.is_some_and(|f| f["managed"] == true) || MANAGED_PATH.is_match(&path) {
204 "managed"
205 } else {
206 "foreign-file"
207 };
208 let active = area["active"] == true;
209 let priority = area["priority"].as_f64();
210 let persistent_priority = persistent.and_then(|p| p["priority"].as_f64());
211 let priority_mode = match persistent {
212 Some(_) if persistent_priority.is_none() => "auto",
213 Some(_) => "custom",
214 None if active && priority.is_some_and(|p| p >= 0.0) => "custom",
215 None => "auto",
216 };
217 let pending = active
218 && persistent.is_some()
219 && match persistent_priority {
220 None => priority.is_some_and(|p| p >= 0.0),
221 Some(pp) => Some(pp) != priority,
222 };
223 let allowed: Vec<&str> = match management {
224 "read-only" => vec![],
225 "partition" | "foreign-file" => vec!["activate", "deactivate", "priority", "persist"],
226 _ => vec!["activate", "deactivate", "priority", "persist", "resize", "delete"],
227 };
228 let map = area.as_object_mut().unwrap();
229 map.insert("fsType".into(), file.map(|f| f["fsType"].clone()).filter(|v| !v.is_null()).unwrap_or(Value::Null));
230 map.insert("mode".into(), file.map(|f| f["mode"].clone()).filter(|v| !v.is_null()).unwrap_or(Value::Null));
231 map.insert("owner".into(), file.map(|f| f["owner"].clone()).filter(|v| !v.is_null() && *v != "").unwrap_or(Value::Null));
232 map.insert("persistent".into(), json!(persistent.is_some()));
233 map.insert("persistentSpec".into(), persistent.map(|p| p["spec"].clone()).unwrap_or(Value::Null));
234 map.insert("persistentAmbiguous".into(), json!(entries.len() > 1));
235 map.insert("persistentPriority".into(), persistent.map(|p| p["priority"].clone()).unwrap_or(Value::Null));
236 map.insert("effectivePriority".into(), if active { area_priority(priority) } else { Value::Null });
237 map.insert("priorityMode".into(), json!(priority_mode));
238 map.insert("priorityPending".into(), json!(pending));
239 map.insert("persistentByChj".into(), json!(persistent.is_some_and(|p| p["managedByChj"] == true)));
240 map.insert("systemdUnit".into(), unit.unwrap_or(Value::Null));
241 map.insert("management".into(), json!(management));
242 map.insert("allowedActions".into(), json!(allowed));
243 area
244 })
245 .collect();
246 // Precedence: the kernel fills higher priorities first; equal priorities are used round-robin.
247 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();
248 priorities.sort_by(|a, b| b.cmp(a));
249 let snapshot = swap_areas.clone();
250 for area in swap_areas.iter_mut() {
251 let active = area["active"] == true;
252 let priority = area["priority"].as_f64().map(|p| p as i64);
253 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 };
254 area["sharedPriority"] = json!(active && snapshot.iter().filter(|o| o["active"] == true && o["priority"].as_f64().map(|p| p as i64) == priority).count() > 1);
255 }
256 let meta_flag = |key: &str| meta.get(key).map(String::as_str) == Some("1");
257 json!({
258 "platform": "linux",
259 "root": meta_flag("root"),
260 "systemd": meta_flag("systemd"),
261 "btrfsMkswapfile": meta_flag("btrfsMkswapfile"),
262 "os": clean_text(meta.get("os").map(String::as_str).unwrap_or(""), 120),
263 "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) },
264 "swapAreas": swap_areas,
265 "fstab": fstab,
266 "units": units,
267 "locations": locations,
268 "zram": zram
269 })
272fn area_priority(priority: Option<f64>) -> Value {
273 priority.map(Value::from).unwrap_or(Value::Null)
276fn parse_steps(stdout: &str) -> Vec<Value> {
277 STEP.captures_iter(stdout).map(|c| json!({ "step": &c[1], "status": &c[2], "message": clean_text(&c[3], 300) })).collect()
280fn normalize_pagefile(value: &Value) -> String {
281 let lower = js_string(value).to_lowercase();
282 let stripped = lower.strip_prefix("\\??\\").unwrap_or(&lower);
283 stripped.split(' ').next().unwrap_or("").to_string()
286pub fn map_windows_state(raw: &Value) -> Value {
287 let mb = |value: &Value| match num_value(value) {
288 Value::Null => Value::Null,
289 v => json!(v.as_f64().unwrap_or(0.0) * 1_048_576.0),
290 };
291 let list = |key: &str| raw[key].as_array().cloned().unwrap_or_default();
292 let configured: Vec<Value> = list("configured")
293 .iter()
294 .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) }))
295 .collect();
296 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();
297 let mut registry: Vec<String> = list("registryPagingFiles").iter().map(normalize_pagefile).collect();
298 registry.sort();
299 let mut existing: Vec<String> = list("existingPageFiles").iter().map(normalize_pagefile).collect();
300 existing.sort();
301 let mut names: Vec<String> = Vec::new();
302 for name in configured.iter().chain(active.iter()).map(|e| js_string(&e["name"]).to_lowercase()) {
303 if !names.contains(&name) {
304 names.push(name);
305 }
306 }
307 let volumes = list("volumes");
308 let pagefiles: Vec<Value> = names
309 .iter()
310 .map(|name| {
311 let setting = configured.iter().find(|e| js_string(&e["name"]).to_lowercase() == *name);
312 let usage = active.iter().find(|e| js_string(&e["name"]).to_lowercase() == *name);
313 let volume = volumes.iter().find(|v| name.starts_with(&js_string(&v["drive"]).to_lowercase()));
314 let display = setting.map(|s| js_string(&s["name"])).or_else(|| usage.map(|u| js_string(&u["name"]))).unwrap_or_default();
315 json!({
316 "name": display,
317 "drive": display.chars().take(2).collect::<String>().to_uppercase(),
318 "configured": setting.is_some(),
319 "active": usage.is_some(),
320 "initial": setting.map(|s| s["initial"].clone()).unwrap_or(Value::Null),
321 "maximum": setting.map(|s| s["maximum"].clone()).unwrap_or(Value::Null),
322 "systemManagedSize": setting.map(|s| s["systemManagedSize"].clone()).unwrap_or(Value::Null),
323 "allocated": usage.map(|u| u["allocated"].clone()).unwrap_or(Value::Null),
324 "used": usage.map(|u| u["used"].clone()).unwrap_or(Value::Null),
325 "peak": usage.map(|u| u["peak"].clone()).unwrap_or(Value::Null),
326 "volumeFree": volume.map(|v| v["free"].clone()).unwrap_or(Value::Null)
327 })
328 })
329 .collect();
330 let pending_truthy = !matches!(&raw["pending"], Value::Null | Value::Bool(false)) && raw["pending"] != 0 && raw["pending"] != "";
331 json!({
332 "platform": "windows",
333 "elevated": raw["elevated"] == true,
334 "automatic": if raw["automatic"].is_boolean() { raw["automatic"].clone() } else { Value::Null },
335 "pagefiles": pagefiles,
336 // Commit limit = physical memory + pagefiles; it is not the pagefile size.
337 "commit": { "limit": num_value(&raw["commitLimit"]), "committed": num_value(&raw["committed"]) },
338 "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<_>>(),
339 "systemDrive": clean_text(&js_string(&raw["systemDrive"]), 3),
340 "crashDumpEnabled": js_number_value(&raw["crashDumpEnabled"]).filter(|n| n.is_finite()).unwrap_or(0.0),
341 "restartPending": pending_truthy || (raw["automatic"] == false && !registry.is_empty() && registry != existing),
342 "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<_>>(),
343 "bootTime": if raw["bootTime"].is_null() || raw["bootTime"] == "" { Value::Null } else { raw["bootTime"].clone() },
344 "unavailable": list("errors").iter().map(|e| clean_text(&js_string(e), 200)).collect::<Vec<_>>()
345 })
348fn windows_error(error: CoreError) -> CoreError {
349 match error.code.strip_prefix("METRICS_") {
350 Some(rest) => CoreError { code: format!("VM_{rest}"), ..error },
351 None => error,
352 }
355fn merge(base: Value, extra: Value) -> Value {
356 let mut map = base.as_object().cloned().unwrap_or_default();
357 for (k, v) in extra.as_object().cloned().unwrap_or_default() {
358 map.insert(k, v);
359 }
360 Value::Object(map)
363pub struct VirtualMemoryService {
364 pub sessions: Arc<SessionManager>,
365 pub executor: Arc<PrivilegedExecutor>,
366 pub logger: Arc<Logger>,
367 running: Mutex<HashSet<String>>,
370fn sudo_password(payload: &Value) -> Option<String> {
371 payload["sudoPassword"].as_str().filter(|s| !s.is_empty()).map(str::to_string)
374impl VirtualMemoryService {
375 pub fn new(sessions: Arc<SessionManager>, executor: Arc<PrivilegedExecutor>, logger: Arc<Logger>) -> Self {
376 Self { sessions, executor, logger, running: Mutex::new(HashSet::new()) }
377 }
379 async fn platform(&self, session_id: &str) -> CoreResult<Value> {
380 let record = self.sessions.require_connected(session_id)?;
381 let platform = detect_platform(&self.sessions, &record, false).await?;
382 if platform["os"] == "unknown" {
383 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"))));
384 }
385 Ok(platform)
386 }
388 async fn windows_script(&self, session_id: &str, operation: &str, args: Value, timeout_ms: u64) -> CoreResult<Value> {
389 let record = self.sessions.require_connected(session_id)?;
390 run_windows_script(&self.sessions, &record, operation, &args, Duration::from_millis(timeout_ms), 4 * 1024 * 1024, "METRICS_").await.map_err(windows_error)
391 }
393 /// `options`: `{ sudoPassword?, privileged? }`.
394 pub async fn read(&self, session_id: &str, options: &Value) -> CoreResult<Value> {
395 let platform = self.platform(session_id).await?;
396 if platform["os"] == "windows" {
397 let raw = self.windows_script(session_id, "system.pagefile.read", json!({}), 90_000).await?;
398 let elevated = raw["elevated"] == true;
399 return Ok(merge(
400 map_windows_state(&raw),
401 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() }),
402 ));
403 }
404 let privilege = self.executor.probe(session_id, false).await?;
405 let privileged = sudo_password(options).is_some() || options["privileged"] == true || ["root", "sudo-nopasswd"].contains(&privilege["mode"].as_str().unwrap_or(""));
406 let run = self
407 .executor
408 .run(
409 session_id,
410 RunOptions {
411 operation: "system.swap.configuration.read".into(),
412 args: vec![],
413 privileged,
414 sudo_password: sudo_password(options),
415 purpose: "system.swap.inspect".into(),
416 error_prefix: "VM_".into(),
417 timeout: Duration::from_secs(60),
418 ..Default::default()
419 },
420 )
421 .await?;
422 let none = privilege["mode"] == "none";
423 Ok(merge(
424 parse_linux_inspect(&run.stdout),
425 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() }),
426 ))
427 }
429 pub async fn manage(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
430 let platform = self.platform(session_id).await?;
431 if !self.running.lock().insert(session_id.to_string()) {
432 return Err(fail("VM_BUSY", "Another virtual memory change is still running for this session."));
433 }
434 let result = if platform["os"] == "windows" { self.manage_windows(session_id, payload).await } else { self.manage_linux(session_id, payload).await };
435 self.running.lock().remove(session_id);
436 result
437 }
439 async fn manage_linux(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
440 let action = js_string(&payload["action"]);
441 if !LINUX_ACTIONS.contains(&action.as_str()) {
442 return Err(fail("VM_ACTION_INVALID", "Unsupported swap action."));
443 }
444 let target = js_string(&payload["path"]);
445 if !ANY_SWAP_PATH.is_match(&target) || target.contains("..") {
446 return Err(fail("VM_SWAP_PATH_INVALID", "Invalid swap path."));
447 }
448 let sized = ["create", "resize"].contains(&action.as_str());
449 if ["create", "resize", "delete"].contains(&action.as_str()) && !MANAGED_PATH.is_match(&target) {
450 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>."));
451 }
452 if target.starts_with("/dev/zram") {
453 return Err(fail("VM_SWAP_ZRAM_READ_ONLY", "zram is shown read-only; CH-J does not configure it."));
454 }
455 let size = if sized { js_number_value(&payload["sizeMiB"]).unwrap_or(f64::NAN) } else { 0.0 };
456 if sized && (size.fract() != 0.0 || !(64.0..=65536.0).contains(&size)) {
457 return Err(fail("VM_SWAP_SIZE_INVALID", "Size must be a whole number of MiB between 64 MiB and 64 GiB."));
458 }
459 // "auto"/-1 = kernel-assigned, 0-32767 = explicit, "keep" = reuse the configured priority (activate/resize/persist).
460 let raw = &payload["priority"];
461 let priority: Result<i64, ()> = match raw {
462 Value::Null => Ok(-1),
463 Value::String(s) if s.is_empty() || s == "auto" => Ok(-1),
464 Value::String(s) if s == "keep" => Err(()),
465 other => match js_number_value(other) {
466 Some(n) if n.fract() == 0.0 && (-1.0..=32767.0).contains(&n) => Ok(n as i64),
467 _ => return Err(fail("VM_SWAP_PRIORITY_INVALID", "Priority must be automatic or a whole number 0-32767.")),
468 },
469 };
470 if priority.is_err() && ["create", "priority"].contains(&action.as_str()) {
471 return Err(fail("VM_SWAP_PRIORITY_INVALID", "Priority must be automatic or a whole number 0-32767."));
472 }
473 let priority_arg = priority.map(|p| p.to_string()).unwrap_or_else(|_| "keep".into());
474 let apply = if payload.get("apply").is_none() || payload["apply"].is_null() { "now".to_string() } else { js_string(&payload["apply"]) };
475 if apply != "now" && apply != "later" {
476 return Err(fail("VM_ARGUMENTS_INVALID", "Apply must be now or later."));
477 }
478 let persist = match payload["persist"] {
479 Value::Bool(true) => "1",
480 Value::Bool(false) => "0",
481 _ => "keep",
482 };
483 if ["create", "persist"].contains(&action.as_str()) && persist == "keep" {
484 return Err(fail("VM_ARGUMENTS_INVALID", "Choose whether the swap space is activated after restart."));
485 }
486 let run = self
487 .executor
488 .run(
489 session_id,
490 RunOptions {
491 operation: format!("system.swap.{action}"),
492 args: vec![target.clone(), (size as i64).to_string(), priority_arg, persist.into(), apply],
493 privileged: true,
494 sudo_password: sudo_password(payload),
495 purpose: format!("system.swap.{action}"),
496 error_prefix: "VM_".into(),
497 timeout: Duration::from_secs(30 * 60),
498 ..Default::default()
499 },
500 )
501 .await?;
502 self.logger.warn("Swap configuration changed.", json!({ "sessionId": session_id, "action": action, "path": target }));
503 let state = self.read(session_id, &json!({ "sudoPassword": payload["sudoPassword"], "privileged": true })).await.ok();
504 let sections = split_sections(&run.stdout);
505 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 }))
506 }
508 async fn manage_windows(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
509 let action = js_string(&payload["action"]);
510 if !WINDOWS_ACTIONS.contains(&action.as_str()) {
511 return Err(fail("VM_ACTION_INVALID", "Unsupported pagefile action."));
512 }
513 let mut args = json!({ "action": action });
514 if action != "automatic" {
515 let drive = js_string(&payload["drive"]).to_uppercase();
516 let bytes = drive.as_bytes();
517 if !(bytes.len() == 2 && bytes[0].is_ascii_uppercase() && bytes[1] == b':') {
518 return Err(fail("VM_PAGEFILE_DRIVE_INVALID", "Invalid drive letter."));
519 }
520 args["drive"] = json!(drive);
521 }
522 if action == "set" {
523 let initial = js_number_value(&payload["initialMb"]).unwrap_or(f64::NAN);
524 let maximum = js_number_value(&payload["maximumMb"]).unwrap_or(f64::NAN);
525 let system_managed = initial == 0.0 && maximum == 0.0;
526 if initial.fract() != 0.0 || maximum.fract() != 0.0 || (!system_managed && (initial < 16.0 || maximum < initial || maximum > 4_194_304.0)) {
527 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."));
528 }
529 args["initialMb"] = json!(initial as i64);
530 args["maximumMb"] = json!(maximum as i64);
531 }
532 if action == "remove" {
533 args["confirmLast"] = json!(payload["confirmLast"] == true);
534 args["confirmCrashDump"] = json!(payload["confirmCrashDump"] == true);
535 }
536 let raw = self.windows_script(session_id, &format!("system.pagefile.{action}"), args, 120_000).await?;
537 self.logger.warn("Windows pagefile configuration changed (restart pending).", json!({ "sessionId": session_id, "action": action }));
538 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() })) }))
539 }
542#[cfg(test)]
543mod tests {
544 use super::*;
546 #[test]
547 fn parses_linux_and_windows_state() {
548 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";
549 let state = parse_linux_inspect(stdout);
550 assert_eq!(state["memory"]["total"], 2048.0 * 1024.0);
551 let areas = state["swapAreas"].as_array().unwrap();
552 assert_eq!(areas[0]["management"], "managed");
553 assert_eq!(areas[0]["priorityMode"], "custom");
554 assert_eq!(areas[0]["priorityPending"], true);
555 assert_eq!(areas[0]["persistentByChj"], true);
556 assert_eq!(areas[1]["type"], "zram");
557 assert_eq!(areas[1]["precedence"], 1);
558 assert_eq!(areas[0]["precedence"], 2);
559 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": [] }));
560 assert_eq!(windows["pagefiles"][0]["systemManagedSize"], true);
561 assert_eq!(windows["restartPending"], true);
562 assert_eq!(unit_name("/swap/my-file"), "swap-my\\x2dfile.swap");
563 }
565 #[tokio::test]
566 async fn priority_and_apply_validation_before_remote_execution() {
567 use crate::core::{profiles::ProfileService, vault::VaultStore};
568 let dir=tempfile::tempdir().unwrap();
569 let sessions=SessionManager::new(Arc::new(ProfileService::new(Arc::new(VaultStore::new(dir.path())))),Arc::new(Logger::new(dir.path())),Arc::new(|_,_|{}));
570 let service=VirtualMemoryService::new(sessions.clone(),Arc::new(PrivilegedExecutor::new(sessions)),Arc::new(Logger::new(dir.path())));
571 for priority in [json!(-3),json!(32768),json!(1.5),json!("abc"),json!("keep")] {
572 let error=service.manage_linux("absent",&json!({"action":"priority","path":"/swapfile-chj","priority":priority})).await.unwrap_err();
573 assert_eq!(error.code,"VM_SWAP_PRIORITY_INVALID","{priority}");
574 }
575 assert_eq!(service.manage_linux("absent",&json!({"action":"priority","path":"/swapfile-chj","priority":5,"apply":"soon"})).await.unwrap_err().code,"VM_ARGUMENTS_INVALID");
576 for priority in [Value::Null,json!(""),json!("auto"),json!(-1),json!(0),json!(100),json!(32767)] {
577 for apply in ["now","later"] {
578 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}");
579 }
580 }
581 for action in ["activate","resize","persist"] {
582 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");
583 }
584 }
586 fn priority_state(swaps:&str,fstab:&str)->Value {
587 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"))
588 }
589 fn area(state:&Value,path:&str)->Value {state["swapAreas"].as_array().unwrap().iter().find(|a|a["path"]==path).unwrap().clone()}
590 #[test]
591 fn automatic_explicit_equal_and_persistent_priority_matrix() {
592 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");
593 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());
594 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");
595 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);
596 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");
597 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);}
598 let p=area(&state,"/dev/chj-test");assert_eq!(p["active"],false);assert_eq!(p["persistentSpec"],"UUID=fixture");assert_eq!(p["management"],"partition");
599 }
SHA-25622895545b3f15510cfc2ed474daea9ba69ef3fd3bd084dd3b84bffbc8a90f67e

CH-J Proprietary Software License 1.14 · کد منبع تحت CH-J Proprietary Software License 1.14 ارائه می‌شود. در دسترس بودن آن شرایط مجوز را تغییر نمی‌دهد و حق اضافه‌ای اعطا نمی‌کند.

SHA-256 بایگانیac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
فایل‌ها