//! Virtual memory for the System Monitor module (port of `system/virtualMemoryService.js`): //! Linux swap (files, partitions, zram, zswap) and Windows pagefiles. Reads never change //! anything; changes go only through fixed operations with validated arguments. use crate::core::error::{CoreError, CoreResult}; use crate::core::logging::Logger; use crate::core::ssh::platform::detect_platform; use crate::core::ssh::privileged::{PrivilegedExecutor, RunOptions}; use crate::core::ssh::session::SessionManager; use crate::core::ssh::windows_exec::run_windows_script; use crate::core::users::common::{clean_text, js_number_value, js_string, key_values, lines, now_iso, split_sections}; use once_cell::sync::Lazy; use parking_lot::Mutex; use regex::Regex; use serde_json::{json, Map, Value}; use std::collections::HashSet; use std::sync::Arc; use std::time::Duration; const SWAP_SCRIPT: &str = include_str!("../../../resources/remote/remoteSwap.sh"); const PAGEFILE_SCRIPT: &str = include_str!("../../../resources/remote/windowsPagefile.ps1"); static MANAGED_PATH: Lazy = 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()); static ANY_SWAP_PATH: Lazy = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/-]{1,199}$").unwrap()); static ZRAM: Lazy = Lazy::new(|| Regex::new(r"^/dev/zram\d+$").unwrap()); static PRI: Lazy = Lazy::new(|| Regex::new(r"(?:^|,)pri=(-?\d+)").unwrap()); static STEP: Lazy = Lazy::new(|| Regex::new(r"\x1eCHJ_STEP=([a-z-]+)\|([a-z]+)\|([^\n]*)\n").unwrap()); const LINUX_ACTIONS: [&str; 7] = ["create", "activate", "deactivate", "priority", "persist", "resize", "delete"]; const WINDOWS_ACTIONS: [&str; 3] = ["automatic", "set", "remove"]; fn fail(code: &str, message: &str) -> CoreError { CoreError::new(code, message) } /// 0.1.0 `number()`: empty/null/non-finite → null. fn number(text: &str) -> Value { if text.is_empty() { return Value::Null; } match js_number_value(&json!(text)) { Some(n) if n.is_finite() => json!(n), _ => Value::Null, } } fn num_value(value: &Value) -> Value { match value { Value::Null => Value::Null, Value::String(s) if s.is_empty() => Value::Null, other => match js_number_value(other) { Some(n) if n.is_finite() => json!(n), _ => Value::Null, }, } } fn js_num(text: &str) -> f64 { js_number_value(&json!(text)).unwrap_or(f64::NAN) } fn json_num(n: f64) -> Value { if n.is_finite() { json!(n) } else { Value::Null } } pub fn parse_proc_swaps(text: &str) -> Vec { lines(text) .into_iter() .skip(1) .map(|line| { let fields: Vec<&str> = line.split_whitespace().collect(); let field = |i: usize| fields.get(i).copied().unwrap_or(""); let path = field(0).replace("\\040", " "); let kind = field(1); json!({ "path": clean_text(&path, 200), "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) }, "size": json_num(js_num(field(2)) * 1024.0), "used": json_num(js_num(field(3)) * 1024.0), "priority": json_num(js_num(field(4))) }) }) .collect() } fn unit_name(path: &str) -> String { format!("{}.swap", path.trim_start_matches('/').replace('-', "\\x2d").replace('/', "-")) } pub fn parse_linux_inspect(stdout: &str) -> Value { let sections = split_sections(stdout); let section = |name: &str| sections.get(name).map(String::as_str).unwrap_or(""); let meta = key_values(section("meta")); let mut meminfo = Map::new(); for line in lines(section("meminfo")) { let mut parts = line.splitn(2, ':'); let key = parts.next().unwrap_or("").to_string(); let value = parts.next().unwrap_or("").trim(); let digits: String = value.chars().take_while(|c| c.is_ascii_digit() || *c == '-').collect(); meminfo.insert(key, digits.parse::().map(|n| json!(n * 1024.0)).unwrap_or(Value::Null)); } let active = parse_proc_swaps(section("swaps")); let fstab: Vec = lines(section("fstab")) .into_iter() .filter_map(|line| { // line|canonical identity|raw line (older helpers sent line|raw). let index = line.find('|')?; let second = line[index + 1..].find('|').map(|i| i + index + 1); let with_canonical = second.is_some_and(|s| !line[index + 1..s].chars().any(char::is_whitespace)); let (raw, resolved) = match (with_canonical, second) { (true, Some(s)) => (&line[s + 1..], json!(clean_text(&line[index + 1..s], 200))), _ => (&line[index + 1..], Value::Null), }; let fields: Vec<&str> = raw.split_whitespace().collect(); let options = fields.get(3).copied().unwrap_or(""); let priority = PRI.captures(options).and_then(|c| c[1].parse::().ok()); let entry = json!({ "line": json_num(js_num(&line[..index])), "spec": clean_text(fields.first().copied().unwrap_or(""), 200), "resolved": resolved, "type": clean_text(fields.get(2).copied().unwrap_or(""), 16), "options": clean_text(options, 120), "priority": priority, "commented": raw.trim().starts_with('#'), "managedByChj": raw.contains("# CH-J chj.system-monitor") }); (entry["type"] == "swap" && entry["commented"] != true).then_some(entry) }) .collect(); let units: Vec = lines(section("units")) .into_iter() .map(|line| { let p: Vec<&str> = line.split('|').collect(); 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) }) }) .collect(); let files: Vec = lines(section("files")) .into_iter() .map(|line| { let p: Vec<&str> = line.split('|').collect(); let f = |i: usize| p.get(i).copied().unwrap_or(""); json!({ "path": clean_text(f(0), 200), "kind": f(1), "fsType": if f(2) == "-" { Value::Null } else { json!(clean_text(f(2), 24)) }, "size": number(f(3)), "owner": if f(4) == "0" && f(1) != "file" { Value::Null } else { json!(clean_text(f(4), 32)) }, "mode": if f(5) == "-" { Value::Null } else { json!(clean_text(f(5), 8)) }, "managed": f(6) == "managed", "diskFree": number(f(7)) }) }) .collect(); let locations: Vec = lines(section("locations")) .into_iter() .map(|line| { let p: Vec<&str> = line.split('|').collect(); 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("")) }) }) .collect(); let zram: Vec = lines(section("zram")) .into_iter() .map(|line| { let p: Vec<&str> = line.split('|').collect(); let values: Vec = p.get(2).copied().unwrap_or("").split(',').map(js_num).collect(); let at = |i: usize| values.get(i).map(|v| json_num(*v)).unwrap_or(Value::Null); json!({ "device": p.first().copied().unwrap_or(""), "diskSize": number(p.get(1).copied().unwrap_or("")), "originalData": at(0), "compressedData": at(1), "memoryUsed": at(2) }) }) .collect(); // Areas keyed by path in insertion order. let mut areas: Vec<(String, Value)> = Vec::new(); for entry in &active { let mut area = entry.clone(); area["active"] = json!(true); let path = js_string(&entry["path"]); match areas.iter_mut().find(|(p, _)| *p == path) { Some(slot) => slot.1 = area, None => areas.push((path, area)), } } for file in &files { let path = js_string(&file["path"]); if !areas.iter().any(|(p, _)| *p == path) { areas.push((path.clone(), json!({ "path": path, "type": file["kind"], "size": file["size"], "used": 0, "priority": null, "active": false }))); } } let matches = |entry: &Value, path: &str| entry["spec"] == path || entry["resolved"] == path; for entry in &fstab { let resolved = entry["resolved"].as_str().filter(|r| r.starts_with('/')); let identity = resolved.map(str::to_string).unwrap_or_else(|| js_string(&entry["spec"])); if identity.starts_with('/') && !areas.iter().any(|(key, _)| matches(entry, key)) { areas.push((identity.clone(), json!({ "path": identity, "type": if identity.starts_with("/dev/") { "partition" } else { "file" }, "size": null, "used": 0, "priority": null, "active": false }))); } } let mut swap_areas: Vec = areas .into_iter() .map(|(path, mut area)| { let file = files.iter().find(|f| f["path"] == path.as_str()); let entries: Vec<&Value> = fstab.iter().filter(|e| matches(e, &path)).collect(); let persistent = entries.first().copied(); let unit = units.iter().find(|u| u["unit"] == unit_name(&path).as_str()).cloned(); let kind = js_string(&area["type"]); let management = if kind == "zram" { "read-only" } else if kind == "partition" { "partition" } else if file.is_some_and(|f| f["managed"] == true) || MANAGED_PATH.is_match(&path) { "managed" } else { "foreign-file" }; let active = area["active"] == true; let priority = area["priority"].as_f64(); let persistent_priority = persistent.and_then(|p| p["priority"].as_f64()); let priority_mode = match persistent { Some(_) if persistent_priority.is_none() => "auto", Some(_) => "custom", None if active && priority.is_some_and(|p| p >= 0.0) => "custom", None => "auto", }; let pending = active && persistent.is_some() && match persistent_priority { None => priority.is_some_and(|p| p >= 0.0), Some(pp) => Some(pp) != priority, }; let allowed: Vec<&str> = match management { "read-only" => vec![], "partition" | "foreign-file" => vec!["activate", "deactivate", "priority", "persist"], _ => vec!["activate", "deactivate", "priority", "persist", "resize", "delete"], }; let map = area.as_object_mut().unwrap(); map.insert("fsType".into(), file.map(|f| f["fsType"].clone()).filter(|v| !v.is_null()).unwrap_or(Value::Null)); map.insert("mode".into(), file.map(|f| f["mode"].clone()).filter(|v| !v.is_null()).unwrap_or(Value::Null)); map.insert("owner".into(), file.map(|f| f["owner"].clone()).filter(|v| !v.is_null() && *v != "").unwrap_or(Value::Null)); map.insert("persistent".into(), json!(persistent.is_some())); map.insert("persistentSpec".into(), persistent.map(|p| p["spec"].clone()).unwrap_or(Value::Null)); map.insert("persistentAmbiguous".into(), json!(entries.len() > 1)); map.insert("persistentPriority".into(), persistent.map(|p| p["priority"].clone()).unwrap_or(Value::Null)); map.insert("effectivePriority".into(), if active { area_priority(priority) } else { Value::Null }); map.insert("priorityMode".into(), json!(priority_mode)); map.insert("priorityPending".into(), json!(pending)); map.insert("persistentByChj".into(), json!(persistent.is_some_and(|p| p["managedByChj"] == true))); map.insert("systemdUnit".into(), unit.unwrap_or(Value::Null)); map.insert("management".into(), json!(management)); map.insert("allowedActions".into(), json!(allowed)); area }) .collect(); // Precedence: the kernel fills higher priorities first; equal priorities are used round-robin. let mut priorities: Vec = swap_areas.iter().filter(|a| a["active"] == true).filter_map(|a| a["priority"].as_f64().map(|p| p as i64)).collect::>().into_iter().collect(); priorities.sort_by(|a, b| b.cmp(a)); let snapshot = swap_areas.clone(); for area in swap_areas.iter_mut() { let active = area["active"] == true; let priority = area["priority"].as_f64().map(|p| p as i64); 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 }; area["sharedPriority"] = json!(active && snapshot.iter().filter(|o| o["active"] == true && o["priority"].as_f64().map(|p| p as i64) == priority).count() > 1); } let meta_flag = |key: &str| meta.get(key).map(String::as_str) == Some("1"); json!({ "platform": "linux", "root": meta_flag("root"), "systemd": meta_flag("systemd"), "btrfsMkswapfile": meta_flag("btrfsMkswapfile"), "os": clean_text(meta.get("os").map(String::as_str).unwrap_or(""), 120), "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) }, "swapAreas": swap_areas, "fstab": fstab, "units": units, "locations": locations, "zram": zram }) } fn area_priority(priority: Option) -> Value { priority.map(Value::from).unwrap_or(Value::Null) } fn parse_steps(stdout: &str) -> Vec { STEP.captures_iter(stdout).map(|c| json!({ "step": &c[1], "status": &c[2], "message": clean_text(&c[3], 300) })).collect() } fn normalize_pagefile(value: &Value) -> String { let lower = js_string(value).to_lowercase(); let stripped = lower.strip_prefix("\\??\\").unwrap_or(&lower); stripped.split(' ').next().unwrap_or("").to_string() } pub fn map_windows_state(raw: &Value) -> Value { let mb = |value: &Value| match num_value(value) { Value::Null => Value::Null, v => json!(v.as_f64().unwrap_or(0.0) * 1_048_576.0), }; let list = |key: &str| raw[key].as_array().cloned().unwrap_or_default(); let configured: Vec = list("configured") .iter() .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) })) .collect(); let active: Vec = 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(); let mut registry: Vec = list("registryPagingFiles").iter().map(normalize_pagefile).collect(); registry.sort(); let mut existing: Vec = list("existingPageFiles").iter().map(normalize_pagefile).collect(); existing.sort(); let mut names: Vec = Vec::new(); for name in configured.iter().chain(active.iter()).map(|e| js_string(&e["name"]).to_lowercase()) { if !names.contains(&name) { names.push(name); } } let volumes = list("volumes"); let pagefiles: Vec = names .iter() .map(|name| { let setting = configured.iter().find(|e| js_string(&e["name"]).to_lowercase() == *name); let usage = active.iter().find(|e| js_string(&e["name"]).to_lowercase() == *name); let volume = volumes.iter().find(|v| name.starts_with(&js_string(&v["drive"]).to_lowercase())); let display = setting.map(|s| js_string(&s["name"])).or_else(|| usage.map(|u| js_string(&u["name"]))).unwrap_or_default(); json!({ "name": display, "drive": display.chars().take(2).collect::().to_uppercase(), "configured": setting.is_some(), "active": usage.is_some(), "initial": setting.map(|s| s["initial"].clone()).unwrap_or(Value::Null), "maximum": setting.map(|s| s["maximum"].clone()).unwrap_or(Value::Null), "systemManagedSize": setting.map(|s| s["systemManagedSize"].clone()).unwrap_or(Value::Null), "allocated": usage.map(|u| u["allocated"].clone()).unwrap_or(Value::Null), "used": usage.map(|u| u["used"].clone()).unwrap_or(Value::Null), "peak": usage.map(|u| u["peak"].clone()).unwrap_or(Value::Null), "volumeFree": volume.map(|v| v["free"].clone()).unwrap_or(Value::Null) }) }) .collect(); let pending_truthy = !matches!(&raw["pending"], Value::Null | Value::Bool(false)) && raw["pending"] != 0 && raw["pending"] != ""; json!({ "platform": "windows", "elevated": raw["elevated"] == true, "automatic": if raw["automatic"].is_boolean() { raw["automatic"].clone() } else { Value::Null }, "pagefiles": pagefiles, // Commit limit = physical memory + pagefiles; it is not the pagefile size. "commit": { "limit": num_value(&raw["commitLimit"]), "committed": num_value(&raw["committed"]) }, "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::>(), "systemDrive": clean_text(&js_string(&raw["systemDrive"]), 3), "crashDumpEnabled": js_number_value(&raw["crashDumpEnabled"]).filter(|n| n.is_finite()).unwrap_or(0.0), "restartPending": pending_truthy || (raw["automatic"] == false && !registry.is_empty() && registry != existing), "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::>(), "bootTime": if raw["bootTime"].is_null() || raw["bootTime"] == "" { Value::Null } else { raw["bootTime"].clone() }, "unavailable": list("errors").iter().map(|e| clean_text(&js_string(e), 200)).collect::>() }) } fn windows_error(error: CoreError) -> CoreError { match error.code.strip_prefix("METRICS_") { Some(rest) => CoreError { code: format!("VM_{rest}"), ..error }, None => error, } } fn merge(base: Value, extra: Value) -> Value { let mut map = base.as_object().cloned().unwrap_or_default(); for (k, v) in extra.as_object().cloned().unwrap_or_default() { map.insert(k, v); } Value::Object(map) } pub struct VirtualMemoryService { pub sessions: Arc, pub executor: Arc, pub logger: Arc, running: Mutex>, } fn sudo_password(payload: &Value) -> Option { payload["sudoPassword"].as_str().filter(|s| !s.is_empty()).map(str::to_string) } impl VirtualMemoryService { pub fn new(sessions: Arc, executor: Arc, logger: Arc) -> Self { Self { sessions, executor, logger, running: Mutex::new(HashSet::new()) } } async fn platform(&self, session_id: &str) -> CoreResult { let record = self.sessions.require_connected(session_id)?; let platform = detect_platform(&self.sessions, &record, false).await?; if platform["os"] == "unknown" { 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")))); } Ok(platform) } async fn windows_script(&self, session_id: &str, args: Value, timeout_ms: u64) -> CoreResult { let record = self.sessions.require_connected(session_id)?; run_windows_script(&self.sessions, &record, PAGEFILE_SCRIPT, &args, Duration::from_millis(timeout_ms), 4 * 1024 * 1024, "METRICS_").await.map_err(windows_error) } /// `options`: `{ sudoPassword?, privileged? }`. pub async fn read(&self, session_id: &str, options: &Value) -> CoreResult { let platform = self.platform(session_id).await?; if platform["os"] == "windows" { let raw = self.windows_script(session_id, json!({ "op": "inspect" }), 90_000).await?; let elevated = raw["elevated"] == true; return Ok(merge( map_windows_state(&raw), 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() }), )); } let privilege = self.executor.probe(session_id, false).await?; let privileged = sudo_password(options).is_some() || options["privileged"] == true || ["root", "sudo-nopasswd"].contains(&privilege["mode"].as_str().unwrap_or("")); let run = self .executor .run( session_id, RunOptions { script: SWAP_SCRIPT.into(), args: vec!["inspect".into()], privileged, sudo_password: sudo_password(options), purpose: "system.swap.inspect".into(), error_prefix: "VM_".into(), timeout: Duration::from_secs(60), ..Default::default() }, ) .await?; let none = privilege["mode"] == "none"; Ok(merge( parse_linux_inspect(&run.stdout), 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() }), )) } pub async fn manage(&self, session_id: &str, payload: &Value) -> CoreResult { let platform = self.platform(session_id).await?; if !self.running.lock().insert(session_id.to_string()) { return Err(fail("VM_BUSY", "Another virtual memory change is still running for this session.")); } let result = if platform["os"] == "windows" { self.manage_windows(session_id, payload).await } else { self.manage_linux(session_id, payload).await }; self.running.lock().remove(session_id); result } async fn manage_linux(&self, session_id: &str, payload: &Value) -> CoreResult { let action = js_string(&payload["action"]); if !LINUX_ACTIONS.contains(&action.as_str()) { return Err(fail("VM_ACTION_INVALID", "Unsupported swap action.")); } let target = js_string(&payload["path"]); if !ANY_SWAP_PATH.is_match(&target) || target.contains("..") { return Err(fail("VM_SWAP_PATH_INVALID", "Invalid swap path.")); } let sized = ["create", "resize"].contains(&action.as_str()); if ["create", "resize", "delete"].contains(&action.as_str()) && !MANAGED_PATH.is_match(&target) { return Err(fail("VM_SWAP_PATH_INVALID", "CH-J creates, resizes and deletes swap files only as /swapfile*, /swap/ or /var/lib/swap/.")); } if target.starts_with("/dev/zram") { return Err(fail("VM_SWAP_ZRAM_READ_ONLY", "zram is shown read-only; CH-J does not configure it.")); } let size = if sized { js_number_value(&payload["sizeMiB"]).unwrap_or(f64::NAN) } else { 0.0 }; if sized && (size.fract() != 0.0 || !(64.0..=65536.0).contains(&size)) { return Err(fail("VM_SWAP_SIZE_INVALID", "Size must be a whole number of MiB between 64 MiB and 64 GiB.")); } // "auto"/-1 = kernel-assigned, 0-32767 = explicit, "keep" = reuse the configured priority (activate/resize/persist). let raw = &payload["priority"]; let priority: Result = match raw { Value::Null => Ok(-1), Value::String(s) if s.is_empty() || s == "auto" => Ok(-1), Value::String(s) if s == "keep" => Err(()), other => match js_number_value(other) { Some(n) if n.fract() == 0.0 && (-1.0..=32767.0).contains(&n) => Ok(n as i64), _ => return Err(fail("VM_SWAP_PRIORITY_INVALID", "Priority must be automatic or a whole number 0-32767.")), }, }; if priority.is_err() && ["create", "priority"].contains(&action.as_str()) { return Err(fail("VM_SWAP_PRIORITY_INVALID", "Priority must be automatic or a whole number 0-32767.")); } let priority_arg = priority.map(|p| p.to_string()).unwrap_or_else(|_| "keep".into()); let apply = if payload.get("apply").is_none() || payload["apply"].is_null() { "now".to_string() } else { js_string(&payload["apply"]) }; if apply != "now" && apply != "later" { return Err(fail("VM_ARGUMENTS_INVALID", "Apply must be now or later.")); } let persist = match payload["persist"] { Value::Bool(true) => "1", Value::Bool(false) => "0", _ => "keep", }; if ["create", "persist"].contains(&action.as_str()) && persist == "keep" { return Err(fail("VM_ARGUMENTS_INVALID", "Choose whether the swap space is activated after restart.")); } let run = self .executor .run( session_id, RunOptions { script: SWAP_SCRIPT.into(), args: vec!["manage".into(), action.clone(), target.clone(), (size as i64).to_string(), priority_arg, persist.into(), apply], privileged: true, sudo_password: sudo_password(payload), purpose: format!("system.swap.{action}"), error_prefix: "VM_".into(), timeout: Duration::from_secs(30 * 60), ..Default::default() }, ) .await?; self.logger.warn("Swap configuration changed.", json!({ "sessionId": session_id, "action": action, "path": target })); let state = self.read(session_id, &json!({ "sudoPassword": payload["sudoPassword"], "privileged": true })).await.ok(); let sections = split_sections(&run.stdout); 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 })) } async fn manage_windows(&self, session_id: &str, payload: &Value) -> CoreResult { let action = js_string(&payload["action"]); if !WINDOWS_ACTIONS.contains(&action.as_str()) { return Err(fail("VM_ACTION_INVALID", "Unsupported pagefile action.")); } let mut args = json!({ "op": "apply", "action": action }); if action != "automatic" { let drive = js_string(&payload["drive"]).to_uppercase(); let bytes = drive.as_bytes(); if !(bytes.len() == 2 && bytes[0].is_ascii_uppercase() && bytes[1] == b':') { return Err(fail("VM_PAGEFILE_DRIVE_INVALID", "Invalid drive letter.")); } args["drive"] = json!(drive); } if action == "set" { let initial = js_number_value(&payload["initialMb"]).unwrap_or(f64::NAN); let maximum = js_number_value(&payload["maximumMb"]).unwrap_or(f64::NAN); let system_managed = initial == 0.0 && maximum == 0.0; if initial.fract() != 0.0 || maximum.fract() != 0.0 || (!system_managed && (initial < 16.0 || maximum < initial || maximum > 4_194_304.0)) { 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.")); } args["initialMb"] = json!(initial as i64); args["maximumMb"] = json!(maximum as i64); } if action == "remove" { args["confirmLast"] = json!(payload["confirmLast"] == true); args["confirmCrashDump"] = json!(payload["confirmCrashDump"] == true); } let raw = self.windows_script(session_id, args, 120_000).await?; self.logger.warn("Windows pagefile configuration changed (restart pending).", json!({ "sessionId": session_id, "action": action })); 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() })) })) } } #[cfg(test)] mod tests { use super::*; #[test] fn parses_linux_and_windows_state() { 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"; let state = parse_linux_inspect(stdout); assert_eq!(state["memory"]["total"], 2048.0 * 1024.0); let areas = state["swapAreas"].as_array().unwrap(); assert_eq!(areas[0]["management"], "managed"); assert_eq!(areas[0]["priorityMode"], "custom"); assert_eq!(areas[0]["priorityPending"], true); assert_eq!(areas[0]["persistentByChj"], true); assert_eq!(areas[1]["type"], "zram"); assert_eq!(areas[1]["precedence"], 1); assert_eq!(areas[0]["precedence"], 2); 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": [] })); assert_eq!(windows["pagefiles"][0]["systemManagedSize"], true); assert_eq!(windows["restartPending"], true); assert_eq!(unit_name("/swap/my-file"), "swap-my\\x2dfile.swap"); } #[tokio::test] async fn priority_and_apply_validation_before_remote_execution() { use crate::core::{profiles::ProfileService, vault::VaultStore}; let dir=tempfile::tempdir().unwrap(); let sessions=SessionManager::new(Arc::new(ProfileService::new(Arc::new(VaultStore::new(dir.path())))),Arc::new(Logger::new(dir.path())),Arc::new(|_,_|{})); let service=VirtualMemoryService::new(sessions.clone(),Arc::new(PrivilegedExecutor::new(sessions)),Arc::new(Logger::new(dir.path()))); for priority in [json!(-3),json!(32768),json!(1.5),json!("abc"),json!("keep")] { let error=service.manage_linux("absent",&json!({"action":"priority","path":"/swapfile-chj","priority":priority})).await.unwrap_err(); assert_eq!(error.code,"VM_SWAP_PRIORITY_INVALID","{priority}"); } assert_eq!(service.manage_linux("absent",&json!({"action":"priority","path":"/swapfile-chj","priority":5,"apply":"soon"})).await.unwrap_err().code,"VM_ARGUMENTS_INVALID"); for priority in [Value::Null,json!(""),json!("auto"),json!(-1),json!(0),json!(100),json!(32767)] { for apply in ["now","later"] { 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}"); } } for action in ["activate","resize","persist"] { 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"); } } fn priority_state(swaps:&str,fstab:&str)->Value { 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")) } fn area(state:&Value,path:&str)->Value {state["swapAreas"].as_array().unwrap().iter().find(|a|a["path"]==path).unwrap().clone()} #[test] fn automatic_explicit_equal_and_persistent_priority_matrix() { 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"); 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()); 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"); 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); 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"); 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);} let p=area(&state,"/dev/chj-test");assert_eq!(p["active"],false);assert_eq!(p["persistentSpec"],"UUID=fixture");assert_eq!(p["management"],"partition"); } }