Opublikowane źródło
Pobierz źródło ZIP
CH-J Tools
Przeglądaj katalogi i pliki dla konkretnego wydania aplikacji.
CH-J / Server Manager
1704 pliki
0.1.2-beta
Dodano: · Built:
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 change3
//! anything; changes go only through fixed operations with validated arguments.5
use crate::core::error::{CoreError, CoreResult};6
use crate::core::logging::Logger;7
use crate::core::ssh::platform::detect_platform;8
use crate::core::ssh::privileged::{PrivilegedExecutor, RunOptions};9
use crate::core::ssh::session::SessionManager;10
use crate::core::ssh::windows_exec::run_windows_script;11
use crate::core::users::common::{clean_text, js_number_value, js_string, key_values, lines, now_iso, split_sections};12
use once_cell::sync::Lazy;13
use parking_lot::Mutex;14
use regex::Regex;15
use serde_json::{json, Map, Value};16
use std::collections::HashSet;17
use std::sync::Arc;18
use std::time::Duration;20
static 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());21
static ANY_SWAP_PATH: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/-]{1,199}$").unwrap());22
static ZRAM: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/dev/zram\d+$").unwrap());23
static PRI: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?:^|,)pri=(-?\d+)").unwrap());24
static STEP: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_STEP=([a-z-]+)\|([a-z]+)\|([^\n]*)\n").unwrap());25
const LINUX_ACTIONS: [&str; 7] = ["create", "activate", "deactivate", "priority", "persist", "resize", "delete"];26
const WINDOWS_ACTIONS: [&str; 3] = ["automatic", "set", "remove"];28
fn fail(code: &str, message: &str) -> CoreError {29
CoreError::new(code, message)30
}32
/// 0.1.0 `number()`: empty/null/non-finite → null.33
fn 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
}41
}43
fn 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
}52
}54
fn js_num(text: &str) -> f64 {55
js_number_value(&json!(text)).unwrap_or(f64::NAN)56
}58
fn json_num(n: f64) -> Value {59
if n.is_finite() { json!(n) } else { Value::Null }60
}62
pub 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()80
}82
fn unit_name(path: &str) -> String {83
format!("{}.swap", path.trim_start_matches('/').replace('-', "\\x2d").replace('/', "-"))84
}86
pub 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> = areas192
.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 = active218
&& 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
area244
})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": zram269
})270
}272
fn area_priority(priority: Option<f64>) -> Value {273
priority.map(Value::from).unwrap_or(Value::Null)274
}276
fn 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()278
}280
fn 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()284
}286
pub 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> = names309
.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
})346
}348
fn 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
}353
}355
fn 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)361
}363
pub struct VirtualMemoryService {364
pub sessions: Arc<SessionManager>,365
pub executor: Arc<PrivilegedExecutor>,366
pub logger: Arc<Logger>,367
running: Mutex<HashSet<String>>,368
}370
fn sudo_password(payload: &Value) -> Option<String> {371
payload["sudoPassword"].as_str().filter(|s| !s.is_empty()).map(str::to_string)372
}374
impl 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 = self407
.executor408
.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
result437
}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 = self487
.executor488
.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
}540
}542
#[cfg(test)]543
mod 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
}601
}SHA-256
22895545b3f15510cfc2ed474daea9ba69ef3fd3bd084dd3b84bffbc8a90f67eCH-J Proprietary Software License 1.14 · Źródło jest dostarczane pod CH-J Proprietary Software License 1.14. Jego dostępność nie zmienia warunków licencji ani nie przyznaje dodatkowych praw.
SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f